From 7e3dfc0347c77bc425d4dc470060e901e633efbe Mon Sep 17 00:00:00 2001 From: Kisslove Dewangan Date: Wed, 26 Aug 2026 18:23:42 +0530 Subject: [PATCH 1/3] feat: add RedPallas MPCv2 DKG support for ZEC Ticket: WCI-1477 TICKET: WCI-1477 --- modules/abstract-lightning/package.json | 2 +- modules/bitgo/package.json | 2 +- modules/express/package.json | 2 +- modules/passkey-crypto/package.json | 2 +- modules/sdk-coin-flrp/package.json | 2 +- modules/sdk-coin-sol/package.json | 2 +- modules/sdk-core/package.json | 2 +- .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 9 +- modules/sdk-core/src/bitgo/tss/common.ts | 3 +- modules/sdk-core/src/bitgo/utils/tss/index.ts | 1 + .../bitgo/utils/tss/redpallas/SMC/utils.ts | 183 +++++++++ .../src/bitgo/utils/tss/redpallas/index.ts | 3 + .../utils/tss/redpallas/redpallasMPCv2.ts | 361 ++++++++++++++++++ .../redpallas/redpallasMPCv2KeyGenSender.ts | 41 ++ .../utils/tss/redpallas/redpallasMPCv2.ts | 244 ++++++++++++ .../unit/bitgo/utils/tss/redpallas/smcUtil.ts | 323 ++++++++++++++++ modules/sdk-lib-mpc/package.json | 2 +- modules/statics/src/utxo.ts | 17 +- modules/statics/test/unit/coins.ts | 35 ++ .../unit/fixtures/expectedColdFeatures.ts | 4 +- yarn.lock | 40 +- 21 files changed, 1257 insertions(+), 23 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/utils/tss/redpallas/SMC/utils.ts create mode 100644 modules/sdk-core/src/bitgo/utils/tss/redpallas/index.ts create mode 100644 modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts create mode 100644 modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2KeyGenSender.ts create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/smcUtil.ts diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index 567dbc43b9..6e199fec52 100644 --- a/modules/abstract-lightning/package.json +++ b/modules/abstract-lightning/package.json @@ -39,7 +39,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.58.0", + "@bitgo/public-types": "6.63.0", "@bitgo/sdk-core": "^38.10.0", "@bitgo/statics": "^59.9.0", "@bitgo/utxo-lib": "^11.24.2", diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index c7fcbb6bee..0a56acf8ed 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -143,7 +143,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.58.0", + "@bitgo/public-types": "6.63.0", "@bitgo/sdk-opensslbytes": "^2.1.0", "@bitgo/sdk-test": "^9.1.70", "@openpgp/web-stream-tools": "0.0.14", diff --git a/modules/express/package.json b/modules/express/package.json index 0208539be4..3976fddb64 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -60,7 +60,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.58.0", + "@bitgo/public-types": "6.63.0", "@bitgo/sdk-lib-mpc": "^10.17.0", "@bitgo/sdk-test": "^9.1.70", "@types/argparse": "^1.0.36", diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index 9818043b57..2c5783d2ee 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -35,7 +35,7 @@ "access": "public" }, "dependencies": { - "@bitgo/public-types": "6.58.0", + "@bitgo/public-types": "6.63.0", "@bitgo/sdk-core": "^38.10.0" }, "devDependencies": { diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index 31c71b4640..82a40fa257 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -48,7 +48,7 @@ "nock": "^13.3.1" }, "dependencies": { - "@bitgo/public-types": "6.58.0", + "@bitgo/public-types": "6.63.0", "@bitgo/sdk-core": "^38.10.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^59.9.0", diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index bb27e4c60f..d44b65257d 100644 --- a/modules/sdk-coin-sol/package.json +++ b/modules/sdk-coin-sol/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@bitgo/logger": "^1.4.0", - "@bitgo/public-types": "6.58.0", + "@bitgo/public-types": "6.63.0", "@bitgo/sdk-core": "^38.10.0", "@bitgo/sdk-lib-mpc": "^10.17.0", "@bitgo/statics": "^59.9.0", diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 28fc718fa5..c57434d061 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.58.0", + "@bitgo/public-types": "6.63.0", "@bitgo/sdk-lib-mpc": "^10.17.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/sjcl": "^1.1.0", diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index ccd5a4adf4..4a825f389c 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -625,7 +625,14 @@ export interface MessagePrep { encodeMessage(message: string): string; } -export type MPCAlgorithm = 'ecdsa' | 'eddsa'; +/** + * 'redpallas' is a DKG-only MPC algorithm (no signing support in this SDK) used for the + * Zcash Orchard shielded pool. It is additive: existing coins never return it from + * `getMPCAlgorithm()` unless explicitly implemented to do so, so this does not change + * behavior for any existing ECDSA/EdDSA coin or for ZEC's existing transparent + * (secp256k1) multisig/TSS flows. + */ +export type MPCAlgorithm = 'ecdsa' | 'eddsa' | 'redpallas'; export type NFTTransferOptions = { tokenContractAddress: string; diff --git a/modules/sdk-core/src/bitgo/tss/common.ts b/modules/sdk-core/src/bitgo/tss/common.ts index 83ec34bad7..97bb251f8d 100644 --- a/modules/sdk-core/src/bitgo/tss/common.ts +++ b/modules/sdk-core/src/bitgo/tss/common.ts @@ -4,6 +4,7 @@ import openpgp from 'openpgp'; import { MPCv2SigningState } from '@bitgo/public-types'; import { BitGoBase } from '../bitgoBase'; import { TxRequestChallengeResponse } from './types'; +import { MPCAlgorithm } from '../baseCoin'; import { RequestType, TxRequest, @@ -144,7 +145,7 @@ export async function sendSignatureShareV2( txRequestId: string, signatureShares: SignatureShareRecord[], requestType: RequestType, - mpcAlgorithm: 'eddsa' | 'ecdsa', + mpcAlgorithm: MPCAlgorithm, signerGpgPublicKey: string, signerShare?: string, multisigTypeVersion?: 'MPCv2' | undefined, diff --git a/modules/sdk-core/src/bitgo/utils/tss/index.ts b/modules/sdk-core/src/bitgo/utils/tss/index.ts index 4604ecd293..3480ade77e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/index.ts @@ -1,6 +1,7 @@ import TssUtils, { TssUtilsTypes, EddsaUtilsTypes } from './eddsa'; export * as ECDSAUtils from './ecdsa'; export * as EDDSAUtils from './eddsa'; +export * as RedpallasUtils from './redpallas'; /** @deprecated use EDDSAUtils */ export { TssUtils }; diff --git a/modules/sdk-core/src/bitgo/utils/tss/redpallas/SMC/utils.ts b/modules/sdk-core/src/bitgo/utils/tss/redpallas/SMC/utils.ts new file mode 100644 index 0000000000..b7b421a021 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/redpallas/SMC/utils.ts @@ -0,0 +1,183 @@ +import assert from 'assert'; +import { + OVCIndexEnum, + RedpallasBitgoToOVC1Round1Response, + RedpallasBitgoToOVC1Round2Response, + RedpallasKeyCreationMPCv2StateEnum, + RedpallasMPCv2KeyGenRound1Response, + RedpallasMPCv2KeyGenRound2Response, + RedpallasOVC1ToBitgoRound1Payload, + RedpallasOVC2ToBitgoRound2Payload, +} from '@bitgo/public-types'; +import { IBaseCoin } from '../../../../baseCoin'; +import { BitGoBase } from '../../../../bitgoBase'; +import { decodeOrElse, Keychain } from '../../../..'; +import { RedpallasMPCv2Utils } from '../redpallasMPCv2'; +import { RedpallasMPCv2KeyGenSendFn, RedpallasKeyGenSenderForEnterprise } from '../redpallasMPCv2KeyGenSender'; + +/** + * Custodial (SMC/OVC) DKG-only key generation for the Zcash Orchard shielded pool (RedPallas). + * + * Mirrors for both custodial and SMC wallet + * ceremonies driven by external OVC (offline vault console) clients: round1/round2 payloads are + * relayed from OVC1/OVC2 to BitGo and back. There is no round3 (no signing) - RedPallas MPS DKG + * completes key generation in 2 online rounds, same as EdDSA MPS DKG. + */ +export class RedpallasMPCv2SMCUtils { + private MPCv2Utils: RedpallasMPCv2Utils; + + constructor(private bitgo: BitGoBase, private baseCoin: IBaseCoin) { + this.MPCv2Utils = new RedpallasMPCv2Utils(bitgo, baseCoin); + } + + public async keyGenRound1( + enterprise: string, + payload: RedpallasOVC1ToBitgoRound1Payload + ): Promise { + return this.keyGenRound1BySender(RedpallasKeyGenSenderForEnterprise(this.bitgo, enterprise), payload); + } + + public async keyGenRound2( + enterprise: string, + payload: RedpallasOVC2ToBitgoRound2Payload + ): Promise { + return this.keyGenRound2BySender(RedpallasKeyGenSenderForEnterprise(this.bitgo, enterprise), payload); + } + + public async keyGenRound1BySender( + senderFn: RedpallasMPCv2KeyGenSendFn, + payload: RedpallasOVC1ToBitgoRound1Payload + ): Promise { + assert( + payload.state === RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data, + `Invalid state for round 1, expected: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data}, got: ${payload.state}` + ); + decodeOrElse(RedpallasOVC1ToBitgoRound1Payload.name, RedpallasOVC1ToBitgoRound1Payload, payload, (errors) => { + throw new Error(`error(s) parsing payload: ${errors}`); + }); + + const ovc1 = payload.ovc[OVCIndexEnum.ONE]; + const ovc2 = payload.ovc[OVCIndexEnum.TWO]; + const result = await this.MPCv2Utils.sendKeyGenerationRound1BySender(senderFn, { + userGpgPublicKey: ovc1.gpgPubKey, + backupGpgPublicKey: ovc2.gpgPubKey, + userMsg1: ovc1.ovcMsg1, + backupMsg1: ovc2.ovcMsg1, + }); + + const response = { + state: RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data, + tssVersion: payload.tssVersion, + walletType: payload.walletType, + coin: payload.coin, + ovc: payload.ovc, + platform: { + sessionId: result.sessionId, + bitgoMsg1: result.bitgoMsg1, + }, + }; + + return decodeOrElse( + RedpallasBitgoToOVC1Round1Response.name, + RedpallasBitgoToOVC1Round1Response, + response, + (errors) => { + throw new Error(`error(s) parsing response: ${errors}`); + } + ); + } + + public async keyGenRound2BySender( + senderFn: RedpallasMPCv2KeyGenSendFn, + payload: RedpallasOVC2ToBitgoRound2Payload + ): Promise { + assert( + payload.state === RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data, + `Invalid state for round 2, expected: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data}, got: ${payload.state}` + ); + decodeOrElse(RedpallasOVC2ToBitgoRound2Payload.name, RedpallasOVC2ToBitgoRound2Payload, payload, (errors) => { + throw new Error(`error(s) parsing payload: ${errors}`); + }); + + const ovc1 = payload.ovc[OVCIndexEnum.ONE]; + const ovc2 = payload.ovc[OVCIndexEnum.TWO]; + const sessionId = payload.platform.sessionId; + const result = await this.MPCv2Utils.sendKeyGenerationRound2BySender(senderFn, { + sessionId, + userMsg2: ovc1.ovcMsg2, + backupMsg2: ovc2.ovcMsg2, + derivationSeed: payload.derivationSeed, + }); + + assert.equal(sessionId, result.sessionId, 'Round 1 and round 2 session IDs do not match'); + + const keychains = this.baseCoin.keychains(); + const bitgoKeychain = await keychains.add({ + source: 'bitgo', + keyType: 'tss', + commonKeychain: result.commonPublicKeychain, + isMPCv2: true, + }); + + const response = { + state: RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1GenerateKey, + bitGoKeyId: bitgoKeychain.id, + tssVersion: payload.tssVersion, + walletType: payload.walletType, + coin: payload.coin, + ovc: payload.ovc, + derivationSeed: payload.derivationSeed, + platform: { + // sessionId/bitgoMsg1 carried over from payload.platform; safe because the assert + // above guarantees payload.platform.sessionId equals result.sessionId. + ...payload.platform, + commonPublicKeychain: result.commonPublicKeychain, + bitgoMsg2: result.bitgoMsg2, + }, + }; + + return decodeOrElse( + RedpallasBitgoToOVC1Round2Response.name, + RedpallasBitgoToOVC1Round2Response, + response, + (errors) => { + throw new Error(`error(s) parsing response: ${errors}`); + } + ); + } + + public async uploadClientKeys( + bitgoKeyId: string, + userCommonKeychain: string, + backupCommonKeychain: string + ): Promise<{ userKeychain: Keychain; backupKeychain: Keychain; bitgoKeychain: Keychain }> { + assert( + userCommonKeychain === backupCommonKeychain, + 'Common keychain mismatch between the user and backup keychains' + ); + + const keychains = this.baseCoin.keychains(); + const bitgoKeychain = await keychains.get({ id: bitgoKeyId }); + assert(bitgoKeychain, 'Keychain not found'); + assert(bitgoKeychain.source === 'bitgo', 'The keychain is not a BitGo keychain'); + assert(bitgoKeychain.type === 'tss', 'BitGo keychain is not a TSS keychain'); + assert(bitgoKeychain.commonKeychain, 'BitGo keychain does not have a common keychain'); + assert(bitgoKeychain.commonKeychain === userCommonKeychain, 'Common keychain mismatch between the OVCs and BitGo'); + + const userKeychainPromise = keychains.add({ + source: 'user', + keyType: 'tss', + commonKeychain: userCommonKeychain, + isMPCv2: true, + }); + const backupKeychainPromise = keychains.add({ + source: 'backup', + keyType: 'tss', + commonKeychain: backupCommonKeychain, + isMPCv2: true, + }); + + const [userKeychain, backupKeychain] = await Promise.all([userKeychainPromise, backupKeychainPromise]); + return { userKeychain, backupKeychain, bitgoKeychain }; + } +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/redpallas/index.ts b/modules/sdk-core/src/bitgo/utils/tss/redpallas/index.ts new file mode 100644 index 0000000000..6de13f37fc --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/redpallas/index.ts @@ -0,0 +1,3 @@ +export * from './redpallasMPCv2'; +export * from './redpallasMPCv2KeyGenSender'; +export * from './SMC/utils'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts new file mode 100644 index 0000000000..9c4d69bee4 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts @@ -0,0 +1,361 @@ +import assert from 'assert'; +import * as pgp from 'openpgp'; +import { NonEmptyString } from 'io-ts-types'; +import { + MPCv2KeyGenStateEnum, + RedpallasMPCv2KeyGenRound1Request, + RedpallasMPCv2KeyGenRound1Response, + RedpallasMPCv2KeyGenRound2Request, + RedpallasMPCv2KeyGenRound2Response, +} from '@bitgo/public-types'; +import { RedPallasMPSDkg, RedPallasMPSTypes, MPSComms } from '@bitgo/sdk-lib-mpc'; +import { IBaseCoin, KeychainsTriplet } from '../../../baseCoin'; +import { BitGoBase } from '../../../bitgoBase'; +import { AddKeychainOptions, Keychain, KeyType } from '../../../keychain'; +import { IWallet } from '../../../wallet'; +import { EncryptionVersion } from '../../../../api'; +import BaseTssUtils from '../baseTSSUtils'; +import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2'; +import { generateGPGKeyPair } from '../../opengpgUtils'; +import { + GenerateRedpallasMPCv2KeyRequestBody, + GenerateRedpallasMPCv2KeyRequestResponse, + RedpallasKeyGenSenderForEnterprise, + RedpallasMPCv2KeyGenSendFn, +} from './redpallasMPCv2KeyGenSender'; + +/** + * DKG-only MPCv2 utils for the Zcash Orchard shielded pool (RedPallas / "Ironwood"). + + * + * Mirrors `EddsaMPCv2Utils` (see `../eddsa/eddsaMPCv2.ts`) for the key-generation portion of the + * protocol only: there is no MPCv1 predecessor to retrofit from, and no signing (DSG) support - + * transaction signing for Zcash shielded addresses is out of scope for this SDK today. + * + * RedPallas MPS DKG completes in the same 2-round shape as EdDSA MPS DKG (round0 local, round1 + + * round2 online), but round2 additionally requires a `derivationSeed`: a 32-byte value consumed by + * a subsequent, platform-side-only key derivation step (Zcash Orchard ask/nk/rivk/ivks) that is + * intentionally not implemented here. The resulting `commonPublicKeychain` is the raw 32-byte + * RedPallas group public key (64 hex chars) - there is no BIP32-style chain code, unlike EdDSA/ECDSA + * commonKeychains. + */ +export class RedpallasMPCv2Utils extends BaseTssUtils { + constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, wallet?: IWallet) { + super(bitgo, baseCoin, wallet); + this.setBitgoGpgPubKey(bitgo); + } + + /** + * Creates user, backup, and BitGo keychains for a RedPallas MPCv2 (DKG-only) wallet. + * + * @param params.passphrase - passphrase to encrypt the user/backup private key shares with + * @param params.enterprise - enterprise id the keys are generated under + * @param params.derivationSeed - 32-byte seed (hex or Buffer) required by DKG round2; must be + * the same value supplied to all three parties (user, backup, BitGo). + */ + async createKeychains(params: { + passphrase: string; + enterprise: string; + derivationSeed: Buffer; + originalPasscodeEncryptionCode?: string; + encryptionVersion?: EncryptionVersion; + safeId?: string; + }): Promise { + assert(params.derivationSeed && params.derivationSeed.length === 32, 'derivationSeed must be 32 bytes'); + + const userKeyPair = await generateGPGKeyPair('ed25519'); + const userGpgKey = await pgp.readPrivateKey({ armoredKey: userKeyPair.privateKey }); + const userGpgPublicKey = userKeyPair.publicKey; + const [userPk, userSk] = await MPSComms.extractEd25519KeyPair(userGpgKey); + + const backupKeyPair = await generateGPGKeyPair('ed25519'); + const backupGpgKey = await pgp.readPrivateKey({ armoredKey: backupKeyPair.privateKey }); + const backupGpgPublicKey = backupKeyPair.publicKey; + const [backupPk, backupSk] = await MPSComms.extractEd25519KeyPair(backupGpgKey); + + // RedPallas DKG needs X25519 keys, extracted the same way as EdDSA MPS DKG does (from an + // ed25519-identity GPG key's encryption subkey) - reuse the same dedicated BitGo GPG key + // used for EdDSA MPCv2, since no separate RedPallas-specific BitGo GPG key is provisioned. + const { eddsaMpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true); + const bitgoPublicGpgKey = eddsaMpcv2PublicKey ?? this.bitgoEddsaMpcv2PublicGpgKey; + assert(bitgoPublicGpgKey, 'Failed to get BitGo GPG public key for RedPallas MPCv2'); + const bitgoPublicGpgKeyArmored = bitgoPublicGpgKey.armor(); + const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoPublicGpgKeyArmored }); + const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj); + + const userDkg = new RedPallasMPSDkg.RedPallasDKG(3, 2, MPCv2PartiesEnum.USER); + const backupDkg = new RedPallasMPSDkg.RedPallasDKG(3, 2, MPCv2PartiesEnum.BACKUP); + + // #region round 1 + await userDkg.initDkg(userSk, [backupPk, bitgoPk]); + await backupDkg.initDkg(backupSk, [userPk, bitgoPk]); + + const userMsg1 = userDkg.getFirstMessage(); + const backupMsg1 = backupDkg.getFirstMessage(); + + const userSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgKey); + const backupSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg1.payload), backupGpgKey); + + assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); + assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); + + const { sessionId, bitgoMsg1 } = await this.sendKeyGenerationRound1(params.enterprise, { + userGpgPublicKey, + backupGpgPublicKey, + userMsg1: userSignedMsg1, + backupMsg1: backupSignedMsg1, + }); + // #endregion + + // #region round 2 + const bitgoRawMsg1Bytes = await MPSComms.verifyMpsMessage(bitgoMsg1, bitgoKeyObj); + const bitgoDeserializedMsg1: RedPallasMPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.BITGO, + payload: new Uint8Array(bitgoRawMsg1Bytes), + }; + + const round1Messages: RedPallasMPSTypes.DeserializedMessages = [userMsg1, backupMsg1, bitgoDeserializedMsg1]; + + const userRound2Msgs = userDkg.handleIncomingMessages(round1Messages); + const backupRound2Msgs = backupDkg.handleIncomingMessages(round1Messages); + + assert(userRound2Msgs.length === 1, 'User round 1 should produce exactly one round 2 message'); + assert(backupRound2Msgs.length === 1, 'Backup round 1 should produce exactly one round 2 message'); + + const userMsg2 = userRound2Msgs[0]; + const backupMsg2 = backupRound2Msgs[0]; + + const userSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgKey); + const backupSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg2.payload), backupGpgKey); + + const { + sessionId: sessionIdRound2, + commonPublicKeychain, + bitgoMsg2, + } = await this.sendKeyGenerationRound2(params.enterprise, { + sessionId, + userMsg2: userSignedMsg2, + backupMsg2: backupSignedMsg2, + derivationSeed: params.derivationSeed.toString('hex'), + }); + // #endregion + + // #region keychain creation + assert.equal(sessionId, sessionIdRound2, 'Round 1 and round 2 session IDs do not match'); + + const bitgoRawMsg2Bytes = await MPSComms.verifyMpsMessage(bitgoMsg2, bitgoKeyObj); + const bitgoDeserializedMsg2: RedPallasMPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.BITGO, + payload: new Uint8Array(bitgoRawMsg2Bytes), + }; + + const round2Messages: RedPallasMPSTypes.DeserializedMessages = [userMsg2, backupMsg2, bitgoDeserializedMsg2]; + + const userFinalMsgs = userDkg.handleIncomingMessages(round2Messages, params.derivationSeed); + const backupFinalMsgs = backupDkg.handleIncomingMessages(round2Messages, params.derivationSeed); + + assert(userFinalMsgs.length === 0, 'DKG round 2 should produce no output messages for user'); + assert(backupFinalMsgs.length === 0, 'DKG round 2 should produce no output messages for backup'); + + const userCommonKeychain = userDkg.getSharePublicKey().toString('hex'); + const backupCommonKeychain = backupDkg.getSharePublicKey().toString('hex'); + + assert.equal( + userCommonKeychain, + commonPublicKeychain, + 'User computed keychain does not match BitGo common keychain' + ); + assert.equal( + backupCommonKeychain, + commonPublicKeychain, + 'Backup computed keychain does not match BitGo common keychain' + ); + + const userPrivateMaterial = userDkg.getKeyShare(); + const backupPrivateMaterial = backupDkg.getKeyShare(); + const userReducedPrivateMaterial = userDkg.getReducedKeyShare(); + const backupReducedPrivateMaterial = backupDkg.getReducedKeyShare(); + + const userKeychainPromise = this.addUserKeychain( + userCommonKeychain, + userPrivateMaterial, + userReducedPrivateMaterial, + params.passphrase, + params.originalPasscodeEncryptionCode, + params.encryptionVersion, + params.safeId + ); + const backupKeychainPromise = this.addBackupKeychain( + backupCommonKeychain, + backupPrivateMaterial, + backupReducedPrivateMaterial, + params.passphrase, + params.originalPasscodeEncryptionCode, + params.encryptionVersion, + params.safeId + ); + const bitgoKeychainPromise = this.addBitgoKeychain(userCommonKeychain, params.safeId); + + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + userKeychainPromise, + backupKeychainPromise, + bitgoKeychainPromise, + ]); + // #endregion + + return { userKeychain, backupKeychain, bitgoKeychain }; + } + + // #region keychain helpers + + async createParticipantKeychain( + participantIndex: MPCv2PartiesEnum, + commonKeychain: string, + privateMaterial?: Buffer, + reducedPrivateMaterial?: Buffer, + passphrase?: string, + originalPasscodeEncryptionCode?: string, + encryptionVersion?: EncryptionVersion, + safeId?: string + ): Promise { + let source: string; + let encryptedPrv: string | undefined = undefined; + let reducedEncryptedPrv: string | undefined = undefined; + + switch (participantIndex) { + case MPCv2PartiesEnum.USER: + case MPCv2PartiesEnum.BACKUP: + source = participantIndex === MPCv2PartiesEnum.USER ? 'user' : 'backup'; + assert(privateMaterial, `Private material is required for ${source} keychain`); + assert(reducedPrivateMaterial, `Reduced private material is required for ${source} keychain`); + assert(passphrase, `Passphrase is required for ${source} keychain`); + encryptedPrv = await this.bitgo.encrypt({ + input: privateMaterial.toString('base64'), + password: passphrase, + encryptionVersion, + }); + reducedEncryptedPrv = await this.bitgo.encrypt({ + input: btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))), + password: passphrase, + encryptionVersion, + }); + break; + case MPCv2PartiesEnum.BITGO: + source = 'bitgo'; + break; + default: + throw new Error('Invalid participant index'); + } + + const keychainParams: AddKeychainOptions = { + source, + keyType: 'tss' as KeyType, + commonKeychain, + encryptedPrv, + originalPasscodeEncryptionCode, + isMPCv2: true, + safeId, + }; + + const keychains = this.baseCoin.keychains(); + return { ...(await keychains.add(keychainParams)), reducedEncryptedPrv }; + } + + private async addUserKeychain( + commonKeychain: string, + privateMaterial: Buffer, + reducedPrivateMaterial: Buffer, + passphrase: string, + originalPasscodeEncryptionCode?: string, + encryptionVersion?: EncryptionVersion, + safeId?: string + ): Promise { + return this.createParticipantKeychain( + MPCv2PartiesEnum.USER, + commonKeychain, + privateMaterial, + reducedPrivateMaterial, + passphrase, + originalPasscodeEncryptionCode, + encryptionVersion, + safeId + ); + } + + private async addBackupKeychain( + commonKeychain: string, + privateMaterial: Buffer, + reducedPrivateMaterial: Buffer, + passphrase: string, + originalPasscodeEncryptionCode?: string, + encryptionVersion?: EncryptionVersion, + safeId?: string + ): Promise { + return this.createParticipantKeychain( + MPCv2PartiesEnum.BACKUP, + commonKeychain, + privateMaterial, + reducedPrivateMaterial, + passphrase, + originalPasscodeEncryptionCode, + encryptionVersion, + safeId + ); + } + + private async addBitgoKeychain(commonKeychain: string, safeId?: string): Promise { + return this.createParticipantKeychain( + MPCv2PartiesEnum.BITGO, + commonKeychain, + undefined, + undefined, + undefined, + undefined, + undefined, + safeId + ); + } + // #endregion + + // #region platform round1/round2 dispatch + + async sendKeyGenerationRound1( + enterprise: string, + payload: RedpallasMPCv2KeyGenRound1Request, + safeId?: string + ): Promise { + return this.sendKeyGenerationRound1BySender( + RedpallasKeyGenSenderForEnterprise(this.bitgo, enterprise, safeId), + payload + ); + } + + async sendKeyGenerationRound1BySender( + senderFn: RedpallasMPCv2KeyGenSendFn, + payload: RedpallasMPCv2KeyGenRound1Request + ): Promise { + return senderFn( + MPCv2KeyGenStateEnum['MPCv2-R1'], + payload as GenerateRedpallasMPCv2KeyRequestBody + ) as Promise; + } + + async sendKeyGenerationRound2( + enterprise: string, + payload: RedpallasMPCv2KeyGenRound2Request + ): Promise { + return this.sendKeyGenerationRound2BySender(RedpallasKeyGenSenderForEnterprise(this.bitgo, enterprise), payload); + } + + async sendKeyGenerationRound2BySender( + senderFn: RedpallasMPCv2KeyGenSendFn, + payload: RedpallasMPCv2KeyGenRound2Request + ): Promise { + return senderFn( + MPCv2KeyGenStateEnum['MPCv2-R2'], + payload as GenerateRedpallasMPCv2KeyRequestBody + ) as Promise; + } + + // #endregion +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2KeyGenSender.ts b/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2KeyGenSender.ts new file mode 100644 index 0000000000..7aacf26de4 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2KeyGenSender.ts @@ -0,0 +1,41 @@ +import * as t from 'io-ts'; +import { + KeyCurveEnum, + KeyGenTypeEnum, + MPCv2KeyGenState, + RedpallasMPCv2KeyGenRound1Request, + RedpallasMPCv2KeyGenRound1Response, + RedpallasMPCv2KeyGenRound2Request, + RedpallasMPCv2KeyGenRound2Response, +} from '@bitgo/public-types'; +import { BitGoBase } from '../../../bitgoBase'; + +const generateRedpallasMPCv2KeyRequestBody = t.union([ + RedpallasMPCv2KeyGenRound1Request, + RedpallasMPCv2KeyGenRound2Request, +]); +export type GenerateRedpallasMPCv2KeyRequestBody = t.TypeOf; + +const generateRedpallasMPCv2KeyRequestResponse = t.union([ + RedpallasMPCv2KeyGenRound1Response, + RedpallasMPCv2KeyGenRound2Response, +]); +export type GenerateRedpallasMPCv2KeyRequestResponse = t.TypeOf; + +export type RedpallasMPCv2KeyGenSendFn = ( + round: MPCv2KeyGenState, + payload: GenerateRedpallasMPCv2KeyRequestBody +) => Promise; + +export function RedpallasKeyGenSenderForEnterprise( + bitgo: BitGoBase, + enterprise: string, + safeId?: string +): RedpallasMPCv2KeyGenSendFn { + return (round, payload) => { + return bitgo + .post(bitgo.url('/mpc/generatekey', 2)) + .send({ enterprise, safeId, type: KeyGenTypeEnum.MPCv2, curveType: KeyCurveEnum.RedPallas, round, payload }) + .result(); + }; +} diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts new file mode 100644 index 0000000000..1745e989ae --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts @@ -0,0 +1,244 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as pgp from 'openpgp'; +import * as crypto from 'crypto'; +import { RedPallasMPSDkg, RedPallasMPSTypes, MPSComms } from '@bitgo/sdk-lib-mpc'; +import { BitGoBase, IBaseCoin, RedpallasUtils, generateGPGKeyPair } from '../../../../../../src'; +import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; + +const { RedpallasMPCv2Utils } = RedpallasUtils; +type RedpallasMPCv2Utils = InstanceType; + +describe('RedpallasMPCv2Utils', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('sendKeyGenerationRound1 / sendKeyGenerationRound2 dispatch', function () { + let utils: RedpallasMPCv2Utils; + + beforeEach(function () { + const mockBitGo = {} as unknown as BitGoBase; + const mockCoin = {} as unknown as IBaseCoin; + utils = new RedpallasMPCv2Utils(mockBitGo, mockCoin); + }); + + it('sendKeyGenerationRound1BySender invokes the sender with the MPCv2-R1 round', async function () { + const senderFn = sinon.stub().resolves({ sessionId: 's1', bitgoMsg1: { message: 'm', signature: 'sig' } }); + const payload = { + userGpgPublicKey: 'user-pub', + backupGpgPublicKey: 'backup-pub', + userMsg1: { message: 'u1', signature: 'usig' }, + backupMsg1: { message: 'b1', signature: 'bsig' }, + }; + + const result = await utils.sendKeyGenerationRound1BySender(senderFn as never, payload as never); + + assert.ok(senderFn.calledOnceWith('MPCv2-R1', payload)); + assert.strictEqual(result.sessionId, 's1'); + }); + + it('sendKeyGenerationRound2BySender invokes the sender with the MPCv2-R2 round', async function () { + const senderFn = sinon.stub().resolves({ + sessionId: 's1', + commonPublicKeychain: 'a'.repeat(64), + bitgoMsg2: { message: 'm', signature: 'sig' }, + }); + const payload = { + sessionId: 's1', + userMsg2: { message: 'u2', signature: 'usig' }, + backupMsg2: { message: 'b2', signature: 'bsig' }, + derivationSeed: 'a'.repeat(64), + }; + + const result = await utils.sendKeyGenerationRound2BySender(senderFn as never, payload as never); + + assert.ok(senderFn.calledOnceWith('MPCv2-R2', payload)); + assert.strictEqual(result.commonPublicKeychain, 'a'.repeat(64)); + }); + }); + + describe('createParticipantKeychain', function () { + let utils: RedpallasMPCv2Utils; + let keychainsStub: { add: sinon.SinonStub }; + let encryptStub: sinon.SinonStub; + + beforeEach(function () { + encryptStub = sinon.stub().resolves('encrypted-value'); + const mockBitGo = { encrypt: encryptStub } as unknown as BitGoBase; + keychainsStub = { + add: sinon.stub().callsFake((params: Record) => Promise.resolve({ id: 'key-id', ...params })), + }; + const mockCoin = { keychains: sinon.stub().returns(keychainsStub) } as unknown as IBaseCoin; + utils = new RedpallasMPCv2Utils(mockBitGo, mockCoin); + }); + + it('creates a user keychain with encrypted private material and reduced private material', async function () { + const privateMaterial = Buffer.from('user-priv'); + const reducedPrivateMaterial = Buffer.from('user-reduced'); + + const keychain = await utils.createParticipantKeychain( + MPCv2PartiesEnum.USER, + 'a'.repeat(64), + privateMaterial, + reducedPrivateMaterial, + 'passphrase' + ); + + assert.strictEqual(keychain.id, 'key-id'); + assert.strictEqual( + (keychain as unknown as { reducedEncryptedPrv: string }).reducedEncryptedPrv, + 'encrypted-value' + ); + assert.ok(encryptStub.calledTwice); + const addArgs = keychainsStub.add.firstCall.args[0]; + assert.strictEqual(addArgs.source, 'user'); + assert.strictEqual(addArgs.commonKeychain, 'a'.repeat(64)); + assert.strictEqual(addArgs.isMPCv2, true); + }); + + it('creates a backup keychain analogously', async function () { + const keychain = await utils.createParticipantKeychain( + MPCv2PartiesEnum.BACKUP, + 'b'.repeat(64), + Buffer.from('backup-priv'), + Buffer.from('backup-reduced'), + 'passphrase' + ); + assert.strictEqual(keychain.id, 'key-id'); + assert.strictEqual(keychainsStub.add.firstCall.args[0].source, 'backup'); + }); + + it('creates a BitGo keychain without private material or passphrase', async function () { + const keychain = await utils.createParticipantKeychain(MPCv2PartiesEnum.BITGO, 'c'.repeat(64)); + assert.strictEqual(keychain.id, 'key-id'); + assert.strictEqual(keychainsStub.add.firstCall.args[0].source, 'bitgo'); + assert.strictEqual(keychainsStub.add.firstCall.args[0].encryptedPrv, undefined); + assert.ok(encryptStub.notCalled); + }); + + it('throws when private material is missing for a user keychain', async function () { + await assert.rejects( + utils.createParticipantKeychain(MPCv2PartiesEnum.USER, 'a'.repeat(64), undefined, undefined, 'passphrase'), + /Private material is required for user keychain/ + ); + }); + + it('throws when passphrase is missing for a backup keychain', async function () { + await assert.rejects( + utils.createParticipantKeychain( + MPCv2PartiesEnum.BACKUP, + 'a'.repeat(64), + Buffer.from('x'), + Buffer.from('y'), + undefined + ), + /Passphrase is required for backup keychain/ + ); + }); + }); + + describe('createKeychains (full DKG round-trip, using a real RedPallas WASM party as BitGo)', function () { + it('produces consistent user/backup/bitgo keychains sharing a common RedPallas public keychain', async function () { + // Simulates BitGo's side of the ceremony with a genuine RedPallasDKG party (index 2), + // driven by intercepting sendKeyGenerationRound1/2 - exercising the real WASM DKG + // end-to-end, the same way `EddsaMPCv2Utils`'s retrofit tests intercept round1. + const bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + const bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); + const [, bitgoSk] = await MPSComms.extractEd25519KeyPair(bitgoGpgPrivKey); + const bitgoDkg = new RedPallasMPSDkg.RedPallasDKG(3, 2, MPCv2PartiesEnum.BITGO); + + const mockBitGo = { + getEnv: sinon.stub().returns('dev'), + encrypt: sinon.stub().resolves('encrypted'), + } as unknown as BitGoBase; + const mockKeychains = { + add: sinon.stub().callsFake((params: Record) => + Promise.resolve({ + id: `${params.source}-key-id`, + commonKeychain: params.commonKeychain, + isMPCv2: true, + source: params.source, + }) + ), + }; + const mockCoin = { keychains: sinon.stub().returns(mockKeychains) } as unknown as IBaseCoin; + + const utils = new RedpallasMPCv2Utils(mockBitGo, mockCoin); + sinon.stub(utils, 'getBitgoGpgPubkeyBasedOnFeatureFlags').resolves({ + mpcv2PublicKey: undefined, + eddsaMpcv2PublicKey: undefined, + } as never); + // Use a real armored GPG public key so pgp.readKey() succeeds inside createKeychains. + (utils as unknown as { bitgoEddsaMpcv2PublicGpgKey: { armor: () => string } }).bitgoEddsaMpcv2PublicGpgKey = { + armor: () => bitgoGpgKeyPair.publicKey, + }; + + let bitgoMsg1: RedPallasMPSTypes.DeserializedMessage; + let userMsg1Captured: RedPallasMPSTypes.DeserializedMessage; + let backupMsg1Captured: RedPallasMPSTypes.DeserializedMessage; + let userGpgKeyObj: pgp.Key; + let backupGpgKeyObj: pgp.Key; + + sinon.stub(utils, 'sendKeyGenerationRound1').callsFake(async (_enterprise, payload) => { + userGpgKeyObj = await pgp.readKey({ armoredKey: payload.userGpgPublicKey }); + backupGpgKeyObj = await pgp.readKey({ armoredKey: payload.backupGpgPublicKey }); + const userPk = await MPSComms.extractEd25519PublicKey(userGpgKeyObj); + const backupPk = await MPSComms.extractEd25519PublicKey(backupGpgKeyObj); + + await bitgoDkg.initDkg(bitgoSk, [userPk, backupPk]); + bitgoMsg1 = bitgoDkg.getFirstMessage(); + + const userRawMsg1 = await MPSComms.verifyMpsMessage(payload.userMsg1, userGpgKeyObj); + const backupRawMsg1 = await MPSComms.verifyMpsMessage(payload.backupMsg1, backupGpgKeyObj); + userMsg1Captured = { from: MPCv2PartiesEnum.USER, payload: new Uint8Array(userRawMsg1) }; + backupMsg1Captured = { from: MPCv2PartiesEnum.BACKUP, payload: new Uint8Array(backupRawMsg1) }; + + const signedBitgoMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + return { sessionId: 'session-1', bitgoMsg1: signedBitgoMsg1 } as never; + }); + + sinon.stub(utils, 'sendKeyGenerationRound2').callsFake(async (_enterprise, payload) => { + const round1Msgs: RedPallasMPSTypes.DeserializedMessages = [userMsg1Captured, backupMsg1Captured, bitgoMsg1]; + const [bitgoMsg2] = bitgoDkg.handleIncomingMessages(round1Msgs); + + const userRawMsg2 = await MPSComms.verifyMpsMessage(payload.userMsg2, userGpgKeyObj); + const backupRawMsg2 = await MPSComms.verifyMpsMessage(payload.backupMsg2, backupGpgKeyObj); + const userMsg2: RedPallasMPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.USER, + payload: new Uint8Array(userRawMsg2), + }; + const backupMsg2: RedPallasMPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.BACKUP, + payload: new Uint8Array(backupRawMsg2), + }; + + const round2Msgs: RedPallasMPSTypes.DeserializedMessages = [userMsg2, backupMsg2, bitgoMsg2]; + const derivationSeed = Buffer.from(payload.derivationSeed, 'hex'); + bitgoDkg.handleIncomingMessages(round2Msgs, derivationSeed); + + const signedBitgoMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); + return { + sessionId: payload.sessionId, + commonPublicKeychain: bitgoDkg.getSharePublicKey().toString('hex'), + bitgoMsg2: signedBitgoMsg2, + } as never; + }); + + const derivationSeed = crypto.randomBytes(32); + const result = await utils.createKeychains({ + passphrase: 'test-passphrase', + enterprise: 'ent-id', + derivationSeed, + }); + + assert.ok(result.userKeychain.commonKeychain); + assert.strictEqual(result.userKeychain.commonKeychain, result.backupKeychain.commonKeychain); + assert.strictEqual(result.userKeychain.commonKeychain, result.bitgoKeychain.commonKeychain); + assert.strictEqual(result.userKeychain.commonKeychain?.length, 64); + assert.strictEqual(result.userKeychain.source, 'user'); + assert.strictEqual(result.backupKeychain.source, 'backup'); + assert.strictEqual(result.bitgoKeychain.source, 'bitgo'); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/smcUtil.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/smcUtil.ts new file mode 100644 index 0000000000..b2b7768571 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/smcUtil.ts @@ -0,0 +1,323 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { + OVCIndexEnum, + RedpallasBitgoToOVC1Round1Response, + RedpallasBitgoToOVC1Round2Response, + RedpallasKeyCreationMPCv2StateEnum, + RedpallasMPCv2KeyGenRound1Response, + RedpallasMPCv2KeyGenRound2Response, + RedpallasOVC1ToBitgoRound1Payload, + RedpallasOVC2ToBitgoRound2Payload, +} from '@bitgo/public-types'; +import { BitGoBase, IBaseCoin } from '../../../../../../src'; +import { RedpallasMPCv2SMCUtils } from '../../../../../../src/bitgo/utils/tss/redpallas/SMC/utils'; + +describe('RedPallas MPCv2 SMC Utils:', function () { + const enterpriseId = '6449153a6f6bc20006d66771cdbe15d3'; + const coinName = 'zec'; + + let smcUtils: RedpallasMPCv2SMCUtils; + let mockBitgo: BitGoBase; + let mockBaseCoin: IBaseCoin; + let keychainsStub: { get: sinon.SinonStub; add: sinon.SinonStub }; + let postChain: { send: sinon.SinonStub; result: sinon.SinonStub }; + + const fakeSignedMessage = (suffix: string) => ({ + message: Buffer.from(`message-${suffix}`).toString('base64'), + signature: `signature-${suffix}`, + }); + + const buildRound1Payload = ( + state: RedpallasKeyCreationMPCv2StateEnum = RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data + ): RedpallasOVC1ToBitgoRound1Payload => + ({ + state, + tssVersion: '0.0.1', + walletType: 'cold', + coin: coinName, + ovc: { + [OVCIndexEnum.ONE]: { + gpgPubKey: 'user-gpg-pubkey', + ovcMsg1: fakeSignedMessage('user-1'), + }, + [OVCIndexEnum.TWO]: { + gpgPubKey: 'backup-gpg-pubkey', + ovcMsg1: fakeSignedMessage('backup-1'), + }, + }, + } as unknown as RedpallasOVC1ToBitgoRound1Payload); + + const buildRound2Payload = ( + sessionId = 'test-session-id', + state: RedpallasKeyCreationMPCv2StateEnum = RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data + ): RedpallasOVC2ToBitgoRound2Payload => + ({ + state, + tssVersion: '0.0.1', + walletType: 'cold', + coin: coinName, + ovc: { + [OVCIndexEnum.ONE]: { + gpgPubKey: 'user-gpg-pubkey', + ovcMsg1: fakeSignedMessage('user-1'), + ovcMsg2: fakeSignedMessage('user-2'), + }, + [OVCIndexEnum.TWO]: { + gpgPubKey: 'backup-gpg-pubkey', + ovcMsg1: fakeSignedMessage('backup-1'), + ovcMsg2: fakeSignedMessage('backup-2'), + }, + }, + platform: { + sessionId, + bitgoMsg1: fakeSignedMessage('bitgo-1'), + }, + derivationSeed: 'a'.repeat(64), + } as unknown as RedpallasOVC2ToBitgoRound2Payload); + + beforeEach(function () { + postChain = { + send: sinon.stub().returnsThis(), + result: sinon.stub(), + }; + mockBitgo = { + post: sinon.stub().returns(postChain), + url: sinon.stub().callsFake((path: string) => `/api/v2${path}`), + } as unknown as BitGoBase; + + keychainsStub = { + get: sinon.stub(), + add: sinon.stub(), + }; + mockBaseCoin = { + keychains: sinon.stub().returns(keychainsStub), + } as unknown as IBaseCoin; + + smcUtils = new RedpallasMPCv2SMCUtils(mockBitgo, mockBaseCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('keyGenRound1BySender', function () { + it('returns a well-formed BitGo→OVC1 round 1 response on success', async function () { + const payload = buildRound1Payload(); + const senderFn = sinon.stub().resolves({ + sessionId: 'session-abc', + bitgoMsg1: fakeSignedMessage('bitgo-r1'), + } as unknown as RedpallasMPCv2KeyGenRound1Response); + + const response = (await smcUtils.keyGenRound1BySender( + senderFn as never, + payload + )) as RedpallasBitgoToOVC1Round1Response; + + assert.strictEqual(response.state, RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data); + assert.strictEqual(response.tssVersion, payload.tssVersion); + assert.strictEqual(response.walletType, payload.walletType); + assert.strictEqual(response.coin, payload.coin); + assert.deepStrictEqual(response.ovc, payload.ovc); + assert.strictEqual(response.platform.sessionId, 'session-abc'); + assert.deepStrictEqual(response.platform.bitgoMsg1, fakeSignedMessage('bitgo-r1')); + assert.ok(senderFn.calledOnce); + + const [, senderPayload] = senderFn.firstCall.args as [unknown, Record]; + assert.strictEqual(senderPayload.userGpgPublicKey, payload.ovc[OVCIndexEnum.ONE].gpgPubKey); + assert.strictEqual(senderPayload.backupGpgPublicKey, payload.ovc[OVCIndexEnum.TWO].gpgPubKey); + assert.deepStrictEqual(senderPayload.userMsg1, payload.ovc[OVCIndexEnum.ONE].ovcMsg1); + assert.deepStrictEqual(senderPayload.backupMsg1, payload.ovc[OVCIndexEnum.TWO].ovcMsg1); + }); + + it('rejects when the payload state is not WaitingForBitgoRound1Data', async function () { + const payload = buildRound1Payload(RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data); + const senderFn = sinon.stub().rejects(new Error('sender should not be invoked')); + + await assert.rejects(smcUtils.keyGenRound1BySender(senderFn as never, payload), { + message: `Invalid state for round 1, expected: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data}, got: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data}`, + }); + assert.ok(senderFn.notCalled); + }); + + it('rejects when the response is malformed (sessionId empty)', async function () { + const payload = buildRound1Payload(); + const senderFn = sinon.stub().resolves({ + sessionId: '', + bitgoMsg1: fakeSignedMessage('bitgo-r1'), + }); + + await assert.rejects(smcUtils.keyGenRound1BySender(senderFn as never, payload), /error\(s\) parsing response/); + }); + }); + + describe('keyGenRound2BySender', function () { + beforeEach(function () { + const fakeKeychain = { + id: 'bitgo-keychain-id', + source: 'bitgo', + type: 'tss' as const, + commonKeychain: 'a'.repeat(64), + }; + keychainsStub.add.resolves(fakeKeychain); + }); + + it('returns a well-formed BitGo→OVC1 round 2 response and adds the BitGo keychain', async function () { + const payload = buildRound2Payload('session-xyz'); + const senderFn = sinon.stub().resolves({ + sessionId: 'session-xyz', + commonPublicKeychain: 'a'.repeat(64), + bitgoMsg2: fakeSignedMessage('bitgo-r2'), + } as unknown as RedpallasMPCv2KeyGenRound2Response); + + const response = (await smcUtils.keyGenRound2BySender( + senderFn as never, + payload + )) as RedpallasBitgoToOVC1Round2Response; + + assert.strictEqual(response.state, RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1GenerateKey); + assert.strictEqual(response.bitGoKeyId, 'bitgo-keychain-id'); + assert.strictEqual(response.tssVersion, payload.tssVersion); + assert.strictEqual(response.walletType, payload.walletType); + assert.strictEqual(response.coin, payload.coin); + assert.deepStrictEqual(response.ovc, payload.ovc); + assert.strictEqual(response.platform.sessionId, 'session-xyz'); + assert.strictEqual(response.platform.commonPublicKeychain, 'a'.repeat(64)); + assert.deepStrictEqual(response.platform.bitgoMsg1, payload.platform.bitgoMsg1); + assert.deepStrictEqual(response.platform.bitgoMsg2, fakeSignedMessage('bitgo-r2')); + + const [, senderPayload] = senderFn.firstCall.args as [unknown, Record]; + assert.strictEqual(senderPayload.sessionId, 'session-xyz'); + assert.deepStrictEqual(senderPayload.userMsg2, payload.ovc[OVCIndexEnum.ONE].ovcMsg2); + assert.deepStrictEqual(senderPayload.backupMsg2, payload.ovc[OVCIndexEnum.TWO].ovcMsg2); + assert.strictEqual(senderPayload.derivationSeed, payload.derivationSeed); + + assert.ok(keychainsStub.add.calledOnce); + assert.deepStrictEqual(keychainsStub.add.firstCall.args[0], { + source: 'bitgo', + keyType: 'tss', + commonKeychain: 'a'.repeat(64), + isMPCv2: true, + }); + }); + + it('rejects when the payload state is not WaitingForBitgoRound2Data', async function () { + const payload = buildRound2Payload('session-xyz', RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC2Round2Data); + const senderFn = sinon.stub().rejects(new Error('sender should not be invoked')); + + await assert.rejects(smcUtils.keyGenRound2BySender(senderFn as never, payload), { + message: `Invalid state for round 2, expected: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data}, got: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC2Round2Data}`, + }); + assert.ok(senderFn.notCalled); + assert.ok(keychainsStub.add.notCalled); + }); + + it('rejects when session IDs returned by BitGo do not match the payload', async function () { + const payload = buildRound2Payload('session-xyz'); + const senderFn = sinon.stub().resolves({ + sessionId: 'different-session-id', + commonPublicKeychain: 'a'.repeat(64), + bitgoMsg2: fakeSignedMessage('bitgo-r2'), + } as unknown as RedpallasMPCv2KeyGenRound2Response); + + await assert.rejects( + smcUtils.keyGenRound2BySender(senderFn as never, payload), + /Round 1 and round 2 session IDs do not match/ + ); + assert.ok(keychainsStub.add.notCalled); + }); + }); + + describe('keyGenRound1 (enterprise)', function () { + it('rejects for an invalid payload state without calling the API', async function () { + const invalidPayload = { + state: RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data, + } as unknown as RedpallasOVC1ToBitgoRound1Payload; + + await assert.rejects(smcUtils.keyGenRound1(enterpriseId, invalidPayload), { + message: `Invalid state for round 1, expected: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data}, got: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data}`, + }); + assert.ok((mockBitgo.post as sinon.SinonStub).notCalled); + }); + + it('POSTs to the MPCv2 generatekey endpoint with curveType RedPallas and returns a parsed round 1 response', async function () { + const payload = buildRound1Payload(); + postChain.result.resolves({ + sessionId: 'enterprise-session', + bitgoMsg1: fakeSignedMessage('bitgo-r1'), + }); + + const response = await smcUtils.keyGenRound1(enterpriseId, payload); + + assert.ok((mockBitgo.post as sinon.SinonStub).calledOnce); + assert.strictEqual((mockBitgo.post as sinon.SinonStub).firstCall.args[0], '/api/v2/mpc/generatekey'); + const sentBody = postChain.send.firstCall.args[0] as { + enterprise: string; + round: string; + curveType: string; + payload: { userGpgPublicKey: string; backupGpgPublicKey: string }; + }; + assert.strictEqual(sentBody.enterprise, enterpriseId); + assert.strictEqual(sentBody.round, 'MPCv2-R1'); + assert.strictEqual(sentBody.curveType, 'RedPallas'); + assert.strictEqual(sentBody.payload.userGpgPublicKey, payload.ovc[OVCIndexEnum.ONE].gpgPubKey); + assert.strictEqual(sentBody.payload.backupGpgPublicKey, payload.ovc[OVCIndexEnum.TWO].gpgPubKey); + + assert.strictEqual(response.state, RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data); + assert.strictEqual(response.platform.sessionId, 'enterprise-session'); + }); + }); + + describe('keyGenRound2 (enterprise)', function () { + it('rejects for an invalid payload state without calling the API', async function () { + const invalidPayload = { + state: RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data, + } as unknown as RedpallasOVC2ToBitgoRound2Payload; + + await assert.rejects(smcUtils.keyGenRound2(enterpriseId, invalidPayload), { + message: `Invalid state for round 2, expected: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data}, got: ${RedpallasKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data}`, + }); + assert.ok((mockBitgo.post as sinon.SinonStub).notCalled); + }); + }); + + describe('uploadClientKeys', function () { + const bitgoKeyId = 'bitgo-key-id'; + const commonKeychain = 'a'.repeat(64); + + it('uploads user/backup keychains and returns the triplet on the happy path', async function () { + const bitgoKeychain = { id: bitgoKeyId, type: 'tss', source: 'bitgo', commonKeychain }; + const userKeychain = { id: 'user-id', type: 'tss', source: 'user', commonKeychain }; + const backupKeychain = { id: 'backup-id', type: 'tss', source: 'backup', commonKeychain }; + + keychainsStub.get.resolves(bitgoKeychain); + keychainsStub.add.withArgs(sinon.match({ source: 'user' })).resolves(userKeychain); + keychainsStub.add.withArgs(sinon.match({ source: 'backup' })).resolves(backupKeychain); + + const result = await smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain); + + assert.deepStrictEqual(result.userKeychain, userKeychain); + assert.deepStrictEqual(result.backupKeychain, backupKeychain); + assert.deepStrictEqual(result.bitgoKeychain, bitgoKeychain); + + assert.ok(keychainsStub.get.calledOnceWithExactly({ id: bitgoKeyId })); + assert.strictEqual(keychainsStub.add.callCount, 2); + }); + + it('rejects when user and backup common keychains differ', async function () { + await assert.rejects( + smcUtils.uploadClientKeys(bitgoKeyId, 'a'.repeat(64), 'b'.repeat(64)), + /Common keychain mismatch between the user and backup keychains/ + ); + assert.ok(keychainsStub.get.notCalled); + assert.ok(keychainsStub.add.notCalled); + }); + + it('rejects when the BitGo keychain cannot be found', async function () { + keychainsStub.get.resolves(undefined); + + await assert.rejects(smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain), /Keychain not found/); + assert.ok(keychainsStub.add.notCalled); + }); + }); +}); diff --git a/modules/sdk-lib-mpc/package.json b/modules/sdk-lib-mpc/package.json index 1d53b51601..678bf69fb4 100644 --- a/modules/sdk-lib-mpc/package.json +++ b/modules/sdk-lib-mpc/package.json @@ -36,7 +36,7 @@ ] }, "dependencies": { - "@bitgo/wasm-mps": "1.12.0", + "@bitgo/wasm-mps": "1.13.0", "@noble/curves": "1.8.1", "@silencelaboratories/dkls-wasm-ll-node": "1.2.0-pre.4", "@silencelaboratories/dkls-wasm-ll-web": "1.2.0-pre.4", diff --git a/modules/statics/src/utxo.ts b/modules/statics/src/utxo.ts index 918277252a..c962501fcd 100644 --- a/modules/statics/src/utxo.ts +++ b/modules/statics/src/utxo.ts @@ -146,7 +146,22 @@ const DOGE_FEATURES = [ ]; const DASH_FEATURES = [...UtxoCoin.DEFAULT_FEATURES, CoinFeature.CUSTODY_BITGO_FRANKFURT, CoinFeature.BULK_TRANSACTION]; const TDASH_FEATURES = [...UtxoCoin.DEFAULT_FEATURES, CoinFeature.BULK_TRANSACTION]; -const ZEC_FEATURES = [...UtxoCoin.DEFAULT_FEATURES, CoinFeature.BULK_TRANSACTION, CoinFeature.CUSTODY_BITGO_FRANKFURT]; +const ZEC_FEATURES = [ + ...UtxoCoin.DEFAULT_FEATURES, + CoinFeature.BULK_TRANSACTION, + CoinFeature.CUSTODY_BITGO_FRANKFURT, + /** + * Zcash also supports a RedPallas/Orchard shielded pool, selectable via + * `otherSupportedKeyCurves` (see the `zec`/`tzec` entries below). TSS/TSS_COLD/MPCV2 are added + * here (alongside the existing MULTISIG/MULTISIG_COLD used for transparent addresses) so + * downstream OVC/BitGo tooling can treat this coin as TSS-eligible for shielded DKG, without + * touching the existing transparent multisig flows which remain keyed on `primaryKeyCurve` + * (secp256k1). + */ + CoinFeature.TSS, + CoinFeature.TSS_COLD, + CoinFeature.MPCV2, +]; const PEARL_FEATURES = [...UtxoCoin.DEFAULT_FEATURES, CoinFeature.BULK_TRANSACTION, CoinFeature.DISTRIBUTED_CUSTODY]; export const utxoCoins: Readonly[] = [ utxo( diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index e151794568..06b8606410 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -1210,6 +1210,41 @@ describe('Cold Wallet Features', () => { }); }); +describe('ZEC RedPallas otherSupportedKeyCurves (shielded pool DKG)', () => { + it('zec and tzec should keep secp256k1 as primary curve and expose Pallas as an other supported curve', () => { + ['zec', 'tzec'].forEach((coinName) => { + const coin = coins.get(coinName) as UtxoCoin; + coin.should.be.instanceof(UtxoCoin); + coin.asset.should.equal(UnderlyingAsset.ZEC); + coin.baseUnit.should.equal(BaseUnit.ZEC); + coin.primaryKeyCurve.should.equal(KeyCurve.Secp256k1); + coin.otherSupportedKeyCurves.should.deepEqual([KeyCurve.Pallas]); + }); + }); + + it('zec and tzec should still support existing transparent multisig cold features unchanged', () => { + ['zec', 'tzec'].forEach((coinName) => { + const coin = coins.get(coinName); + coin.features.includes(CoinFeature.MULTISIG).should.eql(true); + coin.features.includes(CoinFeature.MULTISIG_COLD).should.eql(true); + }); + }); + + it('zec and tzec should now also be TSS-eligible for shielded RedPallas DKG', () => { + ['zec', 'tzec'].forEach((coinName) => { + const coin = coins.get(coinName); + coin.features.includes(CoinFeature.TSS).should.eql(true); + coin.features.includes(CoinFeature.TSS_COLD).should.eql(true); + coin.features.includes(CoinFeature.MPCV2).should.eql(true); + }); + }); + + it('coins without an explicit otherSupportedKeyCurves should leave it undefined', () => { + const btc = coins.get('btc'); + (btc.otherSupportedKeyCurves === undefined).should.be.true(); + }); +}); + describe('Distributed Custody Features', () => { it('btc and tbtc should have distributed custody feature', () => { const targetCoins = ['tbtc', 'btc']; diff --git a/modules/statics/test/unit/fixtures/expectedColdFeatures.ts b/modules/statics/test/unit/fixtures/expectedColdFeatures.ts index 602449c10c..97af01a5bb 100644 --- a/modules/statics/test/unit/fixtures/expectedColdFeatures.ts +++ b/modules/statics/test/unit/fixtures/expectedColdFeatures.ts @@ -16,6 +16,8 @@ export const expectedColdFeatures = { 'ttrx', 'hyperliquid', 'thyperliquid', + 'zec', + 'tzec', ], justMultiSig: [ 'algo', @@ -61,12 +63,10 @@ export const expectedColdFeatures = { 'txlm', 'txrp', 'txtz', - 'tzec', 'tzketh', 'xlm', 'xrp', 'xtz', - 'zec', 'zketh', ], justTSS: [ diff --git a/yarn.lock b/yarn.lock index 410e77597c..3ba88ca85e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1021,10 +1021,10 @@ "@scure/base" "1.1.5" micro-eth-signer "0.7.2" -"@bitgo/public-types@6.58.0": - version "6.58.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.58.0.tgz#9c63e1703456cca8e9498d6251aeb6e51c938ec0" - integrity sha512-2yrxrj5c0dxc02MeeNLYxiCirxejVUpMswjRldNrTv7ipsL+rGU7aHmi0bPh8MHm0vAb0ikkhwbGDKVwQBDD/Q== +"@bitgo/public-types@6.63.0": + version "6.63.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.63.0.tgz#8f19956f043f4e4e20f6dc52b04ae03a8d227489" + integrity sha512-SaZOl4kCxr7HzJQtupIvqofMrdAQhX42KyRmxfG51j6Hh7mPnO2Ny7dTiKUwU1zh5u8MmTXpyIFwscyVW00BpA== dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4" @@ -1037,10 +1037,10 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz" integrity sha512-KoXavJvyDHlEN+sWcigbgxYJtdFaU7gS0EkYQbNH4npVjNlzo6rL6gwjyWbyOy7oEs65DhpJ9vY5kRbE/bKiTQ== -"@bitgo/wasm-mps@1.12.0": - version "1.12.0" - resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.12.0.tgz#03f9fc8eaa25d3dcb5af61915bba890759110c65" - integrity sha512-rude1gS5ml/I/qpkCoeBwvMbveNQp4cWxWzh3wUO4SsXebJMHVmGmWE27EsTBDEiaYI470q5H4aI/oyAfENOUg== +"@bitgo/wasm-mps@1.13.0": + version "1.13.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.13.0.tgz#375dd227ff3b531a6a740e0b1220717fff7e69de" + integrity sha512-Q+15btbNLbe3vMAaGqcMwXI9oAFTb85Ix951KjwOs97Worrj25RtJdri4vOFzRkzkwQsGCmeEDo4U1DvFmjykw== "@bitgo/wasm-solana@^2.6.0": version "2.6.0" @@ -12795,7 +12795,18 @@ html-minifier-terser@^6.0.2: tapable "^1.1.3" util.promisify "1.0.0" -"html-webpack-plugin-5@npm:html-webpack-plugin@^5", html-webpack-plugin@^5.5.0: +"html-webpack-plugin-5@npm:html-webpack-plugin@^5": + version "5.6.4" + resolved "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz" + integrity sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw== + dependencies: + "@types/html-minifier-terser" "^6.0.0" + html-minifier-terser "^6.0.2" + lodash "^4.17.21" + pretty-error "^4.0.0" + tapable "^2.0.0" + +html-webpack-plugin@^5.5.0: version "5.6.4" resolved "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz" integrity sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw== @@ -21360,7 +21371,16 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 063b5bb24d5ebeddd1c901c3fcea780fcb6de715 Mon Sep 17 00:00:00 2001 From: Kisslove Dewangan Date: Wed, 26 Aug 2026 21:14:41 +0530 Subject: [PATCH 2/3] feat: keep only red pallas custodial implementations TICKET: WCI-1477 --- .../utils/tss/redpallas/redpallasMPCv2.ts | 291 +----------------- .../utils/tss/redpallas/redpallasMPCv2.ts | 190 +----------- 2 files changed, 7 insertions(+), 474 deletions(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts index 9c4d69bee4..7b0d1ab08d 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/redpallas/redpallasMPCv2.ts @@ -1,6 +1,3 @@ -import assert from 'assert'; -import * as pgp from 'openpgp'; -import { NonEmptyString } from 'io-ts-types'; import { MPCv2KeyGenStateEnum, RedpallasMPCv2KeyGenRound1Request, @@ -8,15 +5,10 @@ import { RedpallasMPCv2KeyGenRound2Request, RedpallasMPCv2KeyGenRound2Response, } from '@bitgo/public-types'; -import { RedPallasMPSDkg, RedPallasMPSTypes, MPSComms } from '@bitgo/sdk-lib-mpc'; -import { IBaseCoin, KeychainsTriplet } from '../../../baseCoin'; +import { IBaseCoin } from '../../../baseCoin'; import { BitGoBase } from '../../../bitgoBase'; -import { AddKeychainOptions, Keychain, KeyType } from '../../../keychain'; import { IWallet } from '../../../wallet'; -import { EncryptionVersion } from '../../../../api'; import BaseTssUtils from '../baseTSSUtils'; -import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2'; -import { generateGPGKeyPair } from '../../opengpgUtils'; import { GenerateRedpallasMPCv2KeyRequestBody, GenerateRedpallasMPCv2KeyRequestResponse, @@ -26,11 +18,12 @@ import { /** * DKG-only MPCv2 utils for the Zcash Orchard shielded pool (RedPallas / "Ironwood"). - * - * Mirrors `EddsaMPCv2Utils` (see `../eddsa/eddsaMPCv2.ts`) for the key-generation portion of the - * protocol only: there is no MPCv1 predecessor to retrofit from, and no signing (DSG) support - - * transaction signing for Zcash shielded addresses is out of scope for this SDK today. + * This currently only exposes the BitGo-platform round1/round2 dispatch used by the custodial + * SMC/OVC ceremony (see `RedpallasMPCv2SMCUtils` in `./SMC/utils.ts`) - there is no self-custody + * (direct, non-OVC) key-generation entrypoint here, since nothing in this SDK constructs one + * today. There is also no signing (DSG) support - transaction signing for Zcash shielded + * addresses is out of scope for this SDK. * * RedPallas MPS DKG completes in the same 2-round shape as EdDSA MPS DKG (round0 local, round1 + * round2 online), but round2 additionally requires a `derivationSeed`: a 32-byte value consumed by @@ -45,278 +38,6 @@ export class RedpallasMPCv2Utils extends BaseTssUtils { this.setBitgoGpgPubKey(bitgo); } - /** - * Creates user, backup, and BitGo keychains for a RedPallas MPCv2 (DKG-only) wallet. - * - * @param params.passphrase - passphrase to encrypt the user/backup private key shares with - * @param params.enterprise - enterprise id the keys are generated under - * @param params.derivationSeed - 32-byte seed (hex or Buffer) required by DKG round2; must be - * the same value supplied to all three parties (user, backup, BitGo). - */ - async createKeychains(params: { - passphrase: string; - enterprise: string; - derivationSeed: Buffer; - originalPasscodeEncryptionCode?: string; - encryptionVersion?: EncryptionVersion; - safeId?: string; - }): Promise { - assert(params.derivationSeed && params.derivationSeed.length === 32, 'derivationSeed must be 32 bytes'); - - const userKeyPair = await generateGPGKeyPair('ed25519'); - const userGpgKey = await pgp.readPrivateKey({ armoredKey: userKeyPair.privateKey }); - const userGpgPublicKey = userKeyPair.publicKey; - const [userPk, userSk] = await MPSComms.extractEd25519KeyPair(userGpgKey); - - const backupKeyPair = await generateGPGKeyPair('ed25519'); - const backupGpgKey = await pgp.readPrivateKey({ armoredKey: backupKeyPair.privateKey }); - const backupGpgPublicKey = backupKeyPair.publicKey; - const [backupPk, backupSk] = await MPSComms.extractEd25519KeyPair(backupGpgKey); - - // RedPallas DKG needs X25519 keys, extracted the same way as EdDSA MPS DKG does (from an - // ed25519-identity GPG key's encryption subkey) - reuse the same dedicated BitGo GPG key - // used for EdDSA MPCv2, since no separate RedPallas-specific BitGo GPG key is provisioned. - const { eddsaMpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true); - const bitgoPublicGpgKey = eddsaMpcv2PublicKey ?? this.bitgoEddsaMpcv2PublicGpgKey; - assert(bitgoPublicGpgKey, 'Failed to get BitGo GPG public key for RedPallas MPCv2'); - const bitgoPublicGpgKeyArmored = bitgoPublicGpgKey.armor(); - const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoPublicGpgKeyArmored }); - const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj); - - const userDkg = new RedPallasMPSDkg.RedPallasDKG(3, 2, MPCv2PartiesEnum.USER); - const backupDkg = new RedPallasMPSDkg.RedPallasDKG(3, 2, MPCv2PartiesEnum.BACKUP); - - // #region round 1 - await userDkg.initDkg(userSk, [backupPk, bitgoPk]); - await backupDkg.initDkg(backupSk, [userPk, bitgoPk]); - - const userMsg1 = userDkg.getFirstMessage(); - const backupMsg1 = backupDkg.getFirstMessage(); - - const userSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgKey); - const backupSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg1.payload), backupGpgKey); - - assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); - assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); - - const { sessionId, bitgoMsg1 } = await this.sendKeyGenerationRound1(params.enterprise, { - userGpgPublicKey, - backupGpgPublicKey, - userMsg1: userSignedMsg1, - backupMsg1: backupSignedMsg1, - }); - // #endregion - - // #region round 2 - const bitgoRawMsg1Bytes = await MPSComms.verifyMpsMessage(bitgoMsg1, bitgoKeyObj); - const bitgoDeserializedMsg1: RedPallasMPSTypes.DeserializedMessage = { - from: MPCv2PartiesEnum.BITGO, - payload: new Uint8Array(bitgoRawMsg1Bytes), - }; - - const round1Messages: RedPallasMPSTypes.DeserializedMessages = [userMsg1, backupMsg1, bitgoDeserializedMsg1]; - - const userRound2Msgs = userDkg.handleIncomingMessages(round1Messages); - const backupRound2Msgs = backupDkg.handleIncomingMessages(round1Messages); - - assert(userRound2Msgs.length === 1, 'User round 1 should produce exactly one round 2 message'); - assert(backupRound2Msgs.length === 1, 'Backup round 1 should produce exactly one round 2 message'); - - const userMsg2 = userRound2Msgs[0]; - const backupMsg2 = backupRound2Msgs[0]; - - const userSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgKey); - const backupSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg2.payload), backupGpgKey); - - const { - sessionId: sessionIdRound2, - commonPublicKeychain, - bitgoMsg2, - } = await this.sendKeyGenerationRound2(params.enterprise, { - sessionId, - userMsg2: userSignedMsg2, - backupMsg2: backupSignedMsg2, - derivationSeed: params.derivationSeed.toString('hex'), - }); - // #endregion - - // #region keychain creation - assert.equal(sessionId, sessionIdRound2, 'Round 1 and round 2 session IDs do not match'); - - const bitgoRawMsg2Bytes = await MPSComms.verifyMpsMessage(bitgoMsg2, bitgoKeyObj); - const bitgoDeserializedMsg2: RedPallasMPSTypes.DeserializedMessage = { - from: MPCv2PartiesEnum.BITGO, - payload: new Uint8Array(bitgoRawMsg2Bytes), - }; - - const round2Messages: RedPallasMPSTypes.DeserializedMessages = [userMsg2, backupMsg2, bitgoDeserializedMsg2]; - - const userFinalMsgs = userDkg.handleIncomingMessages(round2Messages, params.derivationSeed); - const backupFinalMsgs = backupDkg.handleIncomingMessages(round2Messages, params.derivationSeed); - - assert(userFinalMsgs.length === 0, 'DKG round 2 should produce no output messages for user'); - assert(backupFinalMsgs.length === 0, 'DKG round 2 should produce no output messages for backup'); - - const userCommonKeychain = userDkg.getSharePublicKey().toString('hex'); - const backupCommonKeychain = backupDkg.getSharePublicKey().toString('hex'); - - assert.equal( - userCommonKeychain, - commonPublicKeychain, - 'User computed keychain does not match BitGo common keychain' - ); - assert.equal( - backupCommonKeychain, - commonPublicKeychain, - 'Backup computed keychain does not match BitGo common keychain' - ); - - const userPrivateMaterial = userDkg.getKeyShare(); - const backupPrivateMaterial = backupDkg.getKeyShare(); - const userReducedPrivateMaterial = userDkg.getReducedKeyShare(); - const backupReducedPrivateMaterial = backupDkg.getReducedKeyShare(); - - const userKeychainPromise = this.addUserKeychain( - userCommonKeychain, - userPrivateMaterial, - userReducedPrivateMaterial, - params.passphrase, - params.originalPasscodeEncryptionCode, - params.encryptionVersion, - params.safeId - ); - const backupKeychainPromise = this.addBackupKeychain( - backupCommonKeychain, - backupPrivateMaterial, - backupReducedPrivateMaterial, - params.passphrase, - params.originalPasscodeEncryptionCode, - params.encryptionVersion, - params.safeId - ); - const bitgoKeychainPromise = this.addBitgoKeychain(userCommonKeychain, params.safeId); - - const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ - userKeychainPromise, - backupKeychainPromise, - bitgoKeychainPromise, - ]); - // #endregion - - return { userKeychain, backupKeychain, bitgoKeychain }; - } - - // #region keychain helpers - - async createParticipantKeychain( - participantIndex: MPCv2PartiesEnum, - commonKeychain: string, - privateMaterial?: Buffer, - reducedPrivateMaterial?: Buffer, - passphrase?: string, - originalPasscodeEncryptionCode?: string, - encryptionVersion?: EncryptionVersion, - safeId?: string - ): Promise { - let source: string; - let encryptedPrv: string | undefined = undefined; - let reducedEncryptedPrv: string | undefined = undefined; - - switch (participantIndex) { - case MPCv2PartiesEnum.USER: - case MPCv2PartiesEnum.BACKUP: - source = participantIndex === MPCv2PartiesEnum.USER ? 'user' : 'backup'; - assert(privateMaterial, `Private material is required for ${source} keychain`); - assert(reducedPrivateMaterial, `Reduced private material is required for ${source} keychain`); - assert(passphrase, `Passphrase is required for ${source} keychain`); - encryptedPrv = await this.bitgo.encrypt({ - input: privateMaterial.toString('base64'), - password: passphrase, - encryptionVersion, - }); - reducedEncryptedPrv = await this.bitgo.encrypt({ - input: btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))), - password: passphrase, - encryptionVersion, - }); - break; - case MPCv2PartiesEnum.BITGO: - source = 'bitgo'; - break; - default: - throw new Error('Invalid participant index'); - } - - const keychainParams: AddKeychainOptions = { - source, - keyType: 'tss' as KeyType, - commonKeychain, - encryptedPrv, - originalPasscodeEncryptionCode, - isMPCv2: true, - safeId, - }; - - const keychains = this.baseCoin.keychains(); - return { ...(await keychains.add(keychainParams)), reducedEncryptedPrv }; - } - - private async addUserKeychain( - commonKeychain: string, - privateMaterial: Buffer, - reducedPrivateMaterial: Buffer, - passphrase: string, - originalPasscodeEncryptionCode?: string, - encryptionVersion?: EncryptionVersion, - safeId?: string - ): Promise { - return this.createParticipantKeychain( - MPCv2PartiesEnum.USER, - commonKeychain, - privateMaterial, - reducedPrivateMaterial, - passphrase, - originalPasscodeEncryptionCode, - encryptionVersion, - safeId - ); - } - - private async addBackupKeychain( - commonKeychain: string, - privateMaterial: Buffer, - reducedPrivateMaterial: Buffer, - passphrase: string, - originalPasscodeEncryptionCode?: string, - encryptionVersion?: EncryptionVersion, - safeId?: string - ): Promise { - return this.createParticipantKeychain( - MPCv2PartiesEnum.BACKUP, - commonKeychain, - privateMaterial, - reducedPrivateMaterial, - passphrase, - originalPasscodeEncryptionCode, - encryptionVersion, - safeId - ); - } - - private async addBitgoKeychain(commonKeychain: string, safeId?: string): Promise { - return this.createParticipantKeychain( - MPCv2PartiesEnum.BITGO, - commonKeychain, - undefined, - undefined, - undefined, - undefined, - undefined, - safeId - ); - } - // #endregion - // #region platform round1/round2 dispatch async sendKeyGenerationRound1( diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts index 1745e989ae..dc88232516 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallas/redpallasMPCv2.ts @@ -1,10 +1,6 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import * as pgp from 'openpgp'; -import * as crypto from 'crypto'; -import { RedPallasMPSDkg, RedPallasMPSTypes, MPSComms } from '@bitgo/sdk-lib-mpc'; -import { BitGoBase, IBaseCoin, RedpallasUtils, generateGPGKeyPair } from '../../../../../../src'; -import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; +import { BitGoBase, IBaseCoin, RedpallasUtils } from '../../../../../../src'; const { RedpallasMPCv2Utils } = RedpallasUtils; type RedpallasMPCv2Utils = InstanceType; @@ -57,188 +53,4 @@ describe('RedpallasMPCv2Utils', function () { assert.strictEqual(result.commonPublicKeychain, 'a'.repeat(64)); }); }); - - describe('createParticipantKeychain', function () { - let utils: RedpallasMPCv2Utils; - let keychainsStub: { add: sinon.SinonStub }; - let encryptStub: sinon.SinonStub; - - beforeEach(function () { - encryptStub = sinon.stub().resolves('encrypted-value'); - const mockBitGo = { encrypt: encryptStub } as unknown as BitGoBase; - keychainsStub = { - add: sinon.stub().callsFake((params: Record) => Promise.resolve({ id: 'key-id', ...params })), - }; - const mockCoin = { keychains: sinon.stub().returns(keychainsStub) } as unknown as IBaseCoin; - utils = new RedpallasMPCv2Utils(mockBitGo, mockCoin); - }); - - it('creates a user keychain with encrypted private material and reduced private material', async function () { - const privateMaterial = Buffer.from('user-priv'); - const reducedPrivateMaterial = Buffer.from('user-reduced'); - - const keychain = await utils.createParticipantKeychain( - MPCv2PartiesEnum.USER, - 'a'.repeat(64), - privateMaterial, - reducedPrivateMaterial, - 'passphrase' - ); - - assert.strictEqual(keychain.id, 'key-id'); - assert.strictEqual( - (keychain as unknown as { reducedEncryptedPrv: string }).reducedEncryptedPrv, - 'encrypted-value' - ); - assert.ok(encryptStub.calledTwice); - const addArgs = keychainsStub.add.firstCall.args[0]; - assert.strictEqual(addArgs.source, 'user'); - assert.strictEqual(addArgs.commonKeychain, 'a'.repeat(64)); - assert.strictEqual(addArgs.isMPCv2, true); - }); - - it('creates a backup keychain analogously', async function () { - const keychain = await utils.createParticipantKeychain( - MPCv2PartiesEnum.BACKUP, - 'b'.repeat(64), - Buffer.from('backup-priv'), - Buffer.from('backup-reduced'), - 'passphrase' - ); - assert.strictEqual(keychain.id, 'key-id'); - assert.strictEqual(keychainsStub.add.firstCall.args[0].source, 'backup'); - }); - - it('creates a BitGo keychain without private material or passphrase', async function () { - const keychain = await utils.createParticipantKeychain(MPCv2PartiesEnum.BITGO, 'c'.repeat(64)); - assert.strictEqual(keychain.id, 'key-id'); - assert.strictEqual(keychainsStub.add.firstCall.args[0].source, 'bitgo'); - assert.strictEqual(keychainsStub.add.firstCall.args[0].encryptedPrv, undefined); - assert.ok(encryptStub.notCalled); - }); - - it('throws when private material is missing for a user keychain', async function () { - await assert.rejects( - utils.createParticipantKeychain(MPCv2PartiesEnum.USER, 'a'.repeat(64), undefined, undefined, 'passphrase'), - /Private material is required for user keychain/ - ); - }); - - it('throws when passphrase is missing for a backup keychain', async function () { - await assert.rejects( - utils.createParticipantKeychain( - MPCv2PartiesEnum.BACKUP, - 'a'.repeat(64), - Buffer.from('x'), - Buffer.from('y'), - undefined - ), - /Passphrase is required for backup keychain/ - ); - }); - }); - - describe('createKeychains (full DKG round-trip, using a real RedPallas WASM party as BitGo)', function () { - it('produces consistent user/backup/bitgo keychains sharing a common RedPallas public keychain', async function () { - // Simulates BitGo's side of the ceremony with a genuine RedPallasDKG party (index 2), - // driven by intercepting sendKeyGenerationRound1/2 - exercising the real WASM DKG - // end-to-end, the same way `EddsaMPCv2Utils`'s retrofit tests intercept round1. - const bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); - const bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); - const [, bitgoSk] = await MPSComms.extractEd25519KeyPair(bitgoGpgPrivKey); - const bitgoDkg = new RedPallasMPSDkg.RedPallasDKG(3, 2, MPCv2PartiesEnum.BITGO); - - const mockBitGo = { - getEnv: sinon.stub().returns('dev'), - encrypt: sinon.stub().resolves('encrypted'), - } as unknown as BitGoBase; - const mockKeychains = { - add: sinon.stub().callsFake((params: Record) => - Promise.resolve({ - id: `${params.source}-key-id`, - commonKeychain: params.commonKeychain, - isMPCv2: true, - source: params.source, - }) - ), - }; - const mockCoin = { keychains: sinon.stub().returns(mockKeychains) } as unknown as IBaseCoin; - - const utils = new RedpallasMPCv2Utils(mockBitGo, mockCoin); - sinon.stub(utils, 'getBitgoGpgPubkeyBasedOnFeatureFlags').resolves({ - mpcv2PublicKey: undefined, - eddsaMpcv2PublicKey: undefined, - } as never); - // Use a real armored GPG public key so pgp.readKey() succeeds inside createKeychains. - (utils as unknown as { bitgoEddsaMpcv2PublicGpgKey: { armor: () => string } }).bitgoEddsaMpcv2PublicGpgKey = { - armor: () => bitgoGpgKeyPair.publicKey, - }; - - let bitgoMsg1: RedPallasMPSTypes.DeserializedMessage; - let userMsg1Captured: RedPallasMPSTypes.DeserializedMessage; - let backupMsg1Captured: RedPallasMPSTypes.DeserializedMessage; - let userGpgKeyObj: pgp.Key; - let backupGpgKeyObj: pgp.Key; - - sinon.stub(utils, 'sendKeyGenerationRound1').callsFake(async (_enterprise, payload) => { - userGpgKeyObj = await pgp.readKey({ armoredKey: payload.userGpgPublicKey }); - backupGpgKeyObj = await pgp.readKey({ armoredKey: payload.backupGpgPublicKey }); - const userPk = await MPSComms.extractEd25519PublicKey(userGpgKeyObj); - const backupPk = await MPSComms.extractEd25519PublicKey(backupGpgKeyObj); - - await bitgoDkg.initDkg(bitgoSk, [userPk, backupPk]); - bitgoMsg1 = bitgoDkg.getFirstMessage(); - - const userRawMsg1 = await MPSComms.verifyMpsMessage(payload.userMsg1, userGpgKeyObj); - const backupRawMsg1 = await MPSComms.verifyMpsMessage(payload.backupMsg1, backupGpgKeyObj); - userMsg1Captured = { from: MPCv2PartiesEnum.USER, payload: new Uint8Array(userRawMsg1) }; - backupMsg1Captured = { from: MPCv2PartiesEnum.BACKUP, payload: new Uint8Array(backupRawMsg1) }; - - const signedBitgoMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); - return { sessionId: 'session-1', bitgoMsg1: signedBitgoMsg1 } as never; - }); - - sinon.stub(utils, 'sendKeyGenerationRound2').callsFake(async (_enterprise, payload) => { - const round1Msgs: RedPallasMPSTypes.DeserializedMessages = [userMsg1Captured, backupMsg1Captured, bitgoMsg1]; - const [bitgoMsg2] = bitgoDkg.handleIncomingMessages(round1Msgs); - - const userRawMsg2 = await MPSComms.verifyMpsMessage(payload.userMsg2, userGpgKeyObj); - const backupRawMsg2 = await MPSComms.verifyMpsMessage(payload.backupMsg2, backupGpgKeyObj); - const userMsg2: RedPallasMPSTypes.DeserializedMessage = { - from: MPCv2PartiesEnum.USER, - payload: new Uint8Array(userRawMsg2), - }; - const backupMsg2: RedPallasMPSTypes.DeserializedMessage = { - from: MPCv2PartiesEnum.BACKUP, - payload: new Uint8Array(backupRawMsg2), - }; - - const round2Msgs: RedPallasMPSTypes.DeserializedMessages = [userMsg2, backupMsg2, bitgoMsg2]; - const derivationSeed = Buffer.from(payload.derivationSeed, 'hex'); - bitgoDkg.handleIncomingMessages(round2Msgs, derivationSeed); - - const signedBitgoMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); - return { - sessionId: payload.sessionId, - commonPublicKeychain: bitgoDkg.getSharePublicKey().toString('hex'), - bitgoMsg2: signedBitgoMsg2, - } as never; - }); - - const derivationSeed = crypto.randomBytes(32); - const result = await utils.createKeychains({ - passphrase: 'test-passphrase', - enterprise: 'ent-id', - derivationSeed, - }); - - assert.ok(result.userKeychain.commonKeychain); - assert.strictEqual(result.userKeychain.commonKeychain, result.backupKeychain.commonKeychain); - assert.strictEqual(result.userKeychain.commonKeychain, result.bitgoKeychain.commonKeychain); - assert.strictEqual(result.userKeychain.commonKeychain?.length, 64); - assert.strictEqual(result.userKeychain.source, 'user'); - assert.strictEqual(result.backupKeychain.source, 'backup'); - assert.strictEqual(result.bitgoKeychain.source, 'bitgo'); - }); - }); }); From 4b3b8d1e44de439103f555de52750f283bec6ef0 Mon Sep 17 00:00:00 2001 From: Kisslove Dewangan Date: Thu, 27 Aug 2026 08:58:00 +0530 Subject: [PATCH 3/3] feat: add RedPallas pubkey to constants and tss/pubkey TICKET: WCI-1477 --- .../sdk-core/src/bitgo/tss/bitgoPubKeys.ts | 24 ++++- .../sdk-core/src/bitgo/utils/opengpgUtils.ts | 13 ++- .../src/bitgo/utils/tss/baseTSSUtils.ts | 42 ++++++-- .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 1 + .../test/unit/bitgo/tss/bitgoPubKeys.ts | 62 ++++++++++++ .../bitgo/utils/tss/redpallasBitgoGpgKey.ts | 95 +++++++++++++++++++ 6 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/tss/bitgoPubKeys.ts create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/redpallasBitgoGpgKey.ts diff --git a/modules/sdk-core/src/bitgo/tss/bitgoPubKeys.ts b/modules/sdk-core/src/bitgo/tss/bitgoPubKeys.ts index 94b0529bde..a20906e45c 100644 --- a/modules/sdk-core/src/bitgo/tss/bitgoPubKeys.ts +++ b/modules/sdk-core/src/bitgo/tss/bitgoPubKeys.ts @@ -32,16 +32,32 @@ export const bitgoMpcGpgPubKeys = { prod: '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEafDHRxYJKwYBBAHaRw8BAQdA4veo9hvphbH0gq+YFo8AwdtTR6SQN4tJ\nOWiA++eBqMvNBUJpdEdvwoQEExYKADYFgmnwx1UCCwkJkM4UKK9zJ+KPAhUK\nAhYAApsDAh4BFiEEB3Cyce1GqKAWbPdJzhQor3Mn4o8AAKS2AQDSo5ubHHyx\nvlAyRUgiyzVvupK8x1Jl/fPf54CHsWlIfgD+OIi8v+73cUoHTW3YxC3B9aPO\nqzAEV58w76dgZ2qeTgjChAQQEwgANgWCafDHVgILCQmQjcXpcUn3c9gCFQoC\nFgACmwMCHgEWIQS4vN2O383+kvuFGdaNxelxSfdz2AAAoVUA/A6NI89QMuvO\ngtX65BnmHP+D8vBJbwTwazDHpS0H62/SAPwJVaRXrRNkOTzc6bcAxg97sFBw\nhxJFzcFpAY0BaT5ods44BGnwx0gSCisGAQQBl1UBBQEBB0DZ18qpCkv6tiua\nCCf4Ct9Yfas7EilWG8LPNevujNh7EAMBCAfChQQYFgoANwWCafDHVgILCQmQ\nzhQor3Mn4o8CFQoCFgADmwQIAh4BFiEEB3Cyce1GqKAWbPdJzhQor3Mn4o8A\nAPBxAQD9MYbwZ6NxRabvF1lFMOAofAM4N8htqy6T0ZItfIt6KgEA9K3sycqT\nnWEFPCkJ1hl6QeCnp0qjGcbLxLRVGTWwxwrOMwRp8MdIFgkrBgEEAdpHDwEB\nB0DYMGRlbkTErdLqeeroxGghkYpoTNPHmgb5barxfnu2oMLASgQYFgoAvAWC\nafDHVgILCQmQzhQor3Mn4o8CFQoCFgACmwICHgGFoAQYFgoANgWCafDHVgIL\nCQmQU3o2I0o7U2YCFQoCFgACmwICHgEWIQRREfH7DgNjyYJM1c9TejYjSjtT\nZgAAa4ABAIPcFd0PzHQIQiMW/mvk57vQeOTSJmnY+/aXZ58ba3ykAP4oC5Mv\nRlTjUAmQdd67FX/Kmm49ayKKExrbUxMCHopGDBYhBAdwsnHtRqigFmz3Sc4U\nKK9zJ+KPAACetAEA0KpGMATxFZto3zinmPaHJAmc33RShm9mQJ7XkN8eIPMB\nAMK9J0qdW9jR7iOmJvLmULqUEAYWHAKJ1Ey9PWDSmUwM\n=REv+\n-----END PGP PUBLIC KEY BLOCK-----\n', }, }, + // NOTE: RedPallas DKG uses the same GPG key type as EdDSA MPCv2 (an ed25519-identity GPG key + // with an X25519 encryption subkey - see `RedPallasDKG` in `@bitgo/sdk-lib-mpc`), so as an + // interim measure this reuses the `eddsaMpcv2` key material verbatim rather than fabricating + // new key material. BitGo's platform/security team should provision dedicated RedPallas + // MPCv2 GPG keys (distinct key pairs held by the nitro/onprem HSM infra) before this is used + // for a production ceremony; swap the values below for the dedicated keys once available. + redpallasMpcv2: { + nitro: { + test: '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEae9GZBYJKwYBBAHaRw8BAQdASKNi5MpJRAvINROrOEYFGmJcYKzW3WM7\nkIPow2z0P/bNGGhzbSA8aHNtQHRlc3QuYml0Z28uY29tPsKEBBMWCgA2BYJp\n70Z8AgsJCZDqsFQZg1ASRAIVCgIWAAKbAwIeARYhBLHdfJtdM3kc4o7/wuqw\nVBmDUBJEAAB6WgD/XYI8GR6BZl32N4fu6VZiJ4Ean7ahPCZ+eKYuc8qnJeMB\nAIYqc0Tw9QcqvgrrZtqizUfbYzJDZjNY2R51LrszLFAOwoQEEBMIADYFgmnv\nRnwCCwkJkJsMk/69BgLvAhUKAhYAApsDAh4BFiEE1QB10qpW/55I4JfamwyT\n/r0GAu8AAGVbAP9l4Ffwk1MRYwMSgsXQdJIY8srwSrWaJqFavRZi2tl0iwD+\nJaJdr2DcPP6Pb/zq4kh7YnhjrEgHv2Ta4qTTJtEEq+bOOARp70ZkEgorBgEE\nAZdVAQUBAQdA7gOSvYvpCo8TmgPBHeTqlPDJpohcuPXYMRQ5g228WRgDAQgH\nwoUEGBYKADcFgmnvRnwCCwkJkOqwVBmDUBJEAhUKAhYAA5sECAIeARYhBLHd\nfJtdM3kc4o7/wuqwVBmDUBJEAADrrQD+N+V+lJLtaC3W6E1pHQhr9I+0FsdJ\n5+dQFcacKPls3k0A/0Jp6WA+BUcErw4ahkm4hkbbjbuwRLG9H4PSJfoFgccH\nzjMEae9GZBYJKwYBBAHaRw8BAQdAa3z6M5sueZ+yaWp6jQ5GCqo53k4dkGxg\npj1kB9XTYvTCwEoEGBYKALwFgmnvRnwCCwkJkOqwVBmDUBJEAhUKAhYAApsC\nAh4BhaAEGBYKADYFgmnvRnwCCwkJkA35LiZC8HBmAhUKAhYAApsCAh4BFiEE\n0Xa0VhKDLiNQVIiaDfkuJkLwcGYAAIODAQD+YEcJeL0Wi8jY5bdJXzE8lRkE\noikaQMn3pR6dPtL9SwD/eXJamcMAS6L59dawdD0gwCZn9GlKXmj6FFXEJdcd\nOQoWIQSx3XybXTN5HOKO/8LqsFQZg1ASRAAAiU4A/ji0YOT8ceTgDQn3a5P4\nRz9Fv+OUZxj9hMc8K1lB0N/FAQDgv6gXtzEnpGOjtiwPNuuRynnh9OeXzhgL\nPcQIOmW6DA==\n=FAug\n-----END PGP PUBLIC KEY BLOCK-----\n', + prod: '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEae97HxYJKwYBBAHaRw8BAQdAkqY/gBSnHQ0bJv6Yik473lXLulCWdhW2\nyEjNq+YEf1bNC0JpdEdvIE5pdHJvwoQEExYKADYFgmnveyICCwkJkKw4yRSD\nFu2mAhUKAhYAApsDAh4BFiEEropjTBjLvFsKdb8ErDjJFIMW7aYAACjjAQC7\n5Sc9kjLdScHTbzsM4l90i9lyHFdTVbruSYvmZrHQAAEAusZMX2qpIJdOdtB7\nyiXeCEMxBm1PcdfdvDSGnUruRQ7ChAQQEwgANgWCae97IgILCQmQtCPvhcuG\nfK8CFQoCFgACmwMCHgEWIQSH0CHgaKWZ1TGhqs+0I++Fy4Z8rwAAM9QA/Ap9\n6YjnvCrv1nO0XT/mqUgSMirGzF6AIk//AfqC8kKHAPsH5I9fKTz8i8vWo6ES\nDSVilnIaEyV6sczxZ2drsLV0Ms44BGnvex8SCisGAQQBl1UBBQEBB0DzGVFB\n74c3y9lShX5DG8or1nJoyjpmb7+aZ710EO7YQwMBCAfChQQYFgoANwWCae97\nIgILCQmQrDjJFIMW7aYCFQoCFgADmwQIAh4BFiEEropjTBjLvFsKdb8ErDjJ\nFIMW7aYAAHthAQCPwozH6edvb4G6JRLFrX8i0rEqCje1yty9sXcwI6XQdwD/\nTm38FbU3qGPop4tePWBTEQYEhE5ams9MIWyvX5P0TA7OMwRp73sfFgkrBgEE\nAdpHDwEBB0AMfvO8IOn4Y9TEtx7IQeutQkgFKapa+jZlVcSmGm8yBsLASgQY\nFgoAvAWCae97IgILCQmQrDjJFIMW7aYCFQoCFgACmwICHgGFoAQYFgoANgWC\nae97IgILCQmQVobXdL8ULVQCFQoCFgACmwICHgEWIQQ+ApqjNkpq4BvXiLdW\nhtd0vxQtVAAAzL8A/2ZASI7HhAAYTmNXvTBRSkfGlgPrurY9cXnwvaUN3Mss\nAP9dCqsaPx6XY5z4GGpGMLVJx4gOu9kpLIonHnZVI6idDhYhBK6KY0wYy7xb\nCnW/BKw4yRSDFu2mAACiPAEA1lDVNbiG0nZFbgatcFF5xKd0urF0f9CpOfpB\ntrIGGbUBAOD7FkKLVXWGFYICYipOUzzGzpFiMNaJl1qbCpa9m1kN\n=R6r4\n-----END PGP PUBLIC KEY BLOCK-----\n', + }, + onprem: { + test: '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEad+QCxYJKwYBBAHaRw8BAQdAG0FBM/1JRvo7KlLvhp1Mwi6IWmV3V9xy\nZZcByg0fDQ3NGGhzbSA8aHNtQHRlc3QuYml0Z28uY29tPsKEBBMWCgA2BYJp\n35AMAgsJCZCuV6d9sal31QIVCgIWAAKbAwIeARYhBKSiXcKo4xMT5wwfh65X\np32xqXfVAAA2sAEAgmk543UetoUoOoOvEAhOrRBbF4h6VwcH9cyR9UGSwygA\n/2KJJadiAvaepqFZxyE77rFM7ZfqhRMsoAc2MfslvuQMwoQEEBMIADYFgmnf\nkA0CCwkJkN2vJwOOuE03AhUKAhYAApsDAh4BFiEEjMSQwTRbUtG1fSvR3a8n\nA464TTcAANNVAQD1RTu/bJmPBRvWbvuIiuT1WUxYsSuoXWwki1YImN1gMAD+\nOPU+v056hkdoD8Rcd8D+HhoNlJAbRbZWg/qjxr+S6lLOOARp35AMEgorBgEE\nAZdVAQUBAQdAgqwA9UhQGuseztLr2ZM189pBjrW6sAJ5m6icDYOWMHEDAQgH\nwoUEGBYKADcFgmnfkAwCCwkJkK5Xp32xqXfVAhUKAhYAA5sECAIeARYhBKSi\nXcKo4xMT5wwfh65Xp32xqXfVAAC4uwEAlkVzGDPJYETIV4pXYpCdaeGLBjm9\ny1sRb2nx9ET7m+4BANpb0vKKBrKZTAx/+rINgWoxKPnKPsycOE8bYHY3zKAN\nzjMEad+QDBYJKwYBBAHaRw8BAQdAanwKEY5QEAPafbhM5/BIJZRyLmyNpBTo\ntntTIq0nOt/CwEoEGBYKALwFgmnfkA0CCwkJkK5Xp32xqXfVAhUKAhYAApsC\nAh4BhaAEGBYKADYFgmnfkA0CCwkJkAuXAU6A6KYvAhUKAhYAApsCAh4BFiEE\neaGtxZYsjWFFYrD6C5cBToDopi8AAEp3AQCP4bCSYbhjNJfGnCCOq24DaozR\nUFg0hNlpfSA9NYZ3bwD/fdtV5m5a1QyvcyGEnv37l1H7UGbQlG1Zp8roqZh3\nqwMWIQSkol3CqOMTE+cMH4euV6d9sal31QAAMegA/i3sBuAwvrBsp8ozp7O2\nzQlIbjuvDBMomIXj1rRmgoOkAQDRqsQpAvITd0LMFN2dCqCBIGAeIqznFX0C\nyqvU0m8sCQ==\n=/R3k\n-----END PGP PUBLIC KEY BLOCK-----\n', + prod: '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEafDHRxYJKwYBBAHaRw8BAQdA4veo9hvphbH0gq+YFo8AwdtTR6SQN4tJ\nOWiA++eBqMvNBUJpdEdvwoQEExYKADYFgmnwx1UCCwkJkM4UKK9zJ+KPAhUK\nAhYAApsDAh4BFiEEB3Cyce1GqKAWbPdJzhQor3Mn4o8AAKS2AQDSo5ubHHyx\nvlAyRUgiyzVvupK8x1Jl/fPf54CHsWlIfgD+OIi8v+73cUoHTW3YxC3B9aPO\nqzAEV58w76dgZ2qeTgjChAQQEwgANgWCafDHVgILCQmQjcXpcUn3c9gCFQoC\nFgACmwMCHgEWIQS4vN2O383+kvuFGdaNxelxSfdz2AAAoVUA/A6NI89QMuvO\ngtX65BnmHP+D8vBJbwTwazDHpS0H62/SAPwJVaRXrRNkOTzc6bcAxg97sFBw\nhxJFzcFpAY0BaT5ods44BGnwx0gSCisGAQQBl1UBBQEBB0DZ18qpCkv6tiua\nCCf4Ct9Yfas7EilWG8LPNevujNh7EAMBCAfChQQYFgoANwWCafDHVgILCQmQ\nzhQor3Mn4o8CFQoCFgADmwQIAh4BFiEEB3Cyce1GqKAWbPdJzhQor3Mn4o8A\nAPBxAQD9MYbwZ6NxRabvF1lFMOAofAM4N8htqy6T0ZItfIt6KgEA9K3sycqT\nnWEFPCkJ1hl6QeCnp0qjGcbLxLRVGTWwxwrOMwRp8MdIFgkrBgEEAdpHDwEB\nB0DYMGRlbkTErdLqeeroxGghkYpoTNPHmgb5barxfnu2oMLASgQYFgoAvAWC\nafDHVgILCQmQzhQor3Mn4o8CFQoCFgACmwICHgGFoAQYFgoANgWCafDHVgIL\nCQmQU3o2I0o7U2YCFQoCFgACmwICHgEWIQRREfH7DgNjyYJM1c9TejYjSjtT\nZgAAa4ABAIPcFd0PzHQIQiMW/mvk57vQeOTSJmnY+/aXZ58ba3ykAP4oC5Mv\nRlTjUAmQdd67FX/Kmm49ayKKExrbUxMCHopGDBYhBAdwsnHtRqigFmz3Sc4U\nKK9zJ+KPAACetAEA0KpGMATxFZto3zinmPaHJAmc33RShm9mQJ7XkN8eIPMB\nAMK9J0qdW9jR7iOmJvLmULqUEAYWHAKJ1Ey9PWDSmUwM\n=REv+\n-----END PGP PUBLIC KEY BLOCK-----\n', + }, + }, }; export function getBitgoMpcGpgPubKey( env: EnvironmentName, pubKeyType: 'nitro' | 'onprem', - mpcVersion: 'mpcv1' | 'mpcv2' | 'eddsaMpcv2' + mpcVersion: 'mpcv1' | 'mpcv2' | 'eddsaMpcv2' | 'redpallasMpcv2' ): string { assert( mpcVersion in bitgoMpcGpgPubKeys, - `Invalid mpcVersion in getBitgoMpcGpgPubKey, got: ${mpcVersion}, expected: mpcv1, mpcv2, or eddsaMpcv2` + `Invalid mpcVersion in getBitgoMpcGpgPubKey, got: ${mpcVersion}, expected: mpcv1, mpcv2, eddsaMpcv2, or redpallasMpcv2` ); assert( pubKeyType in bitgoMpcGpgPubKeys[mpcVersion], @@ -72,6 +88,10 @@ export function isBitgoEddsaMpcv2PubKey(key: string): boolean { return Object.values(bitgoMpcGpgPubKeys.eddsaMpcv2).some((envKeys) => Object.values(envKeys).includes(key)); } +export function isBitgoRedpallasMpcv2PubKey(key: string): boolean { + return Object.values(bitgoMpcGpgPubKeys.redpallasMpcv2).some((envKeys) => Object.values(envKeys).includes(key)); +} + export function envRequiresBitgoPubGpgKeyConfig(env: EnvironmentName): boolean { return env === 'prod' || env === 'test' || env === 'staging' || env === 'adminProd' || env === 'adminTest'; } diff --git a/modules/sdk-core/src/bitgo/utils/opengpgUtils.ts b/modules/sdk-core/src/bitgo/utils/opengpgUtils.ts index 8a185697c0..ef1a87e75d 100644 --- a/modules/sdk-core/src/bitgo/utils/opengpgUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/opengpgUtils.ts @@ -35,9 +35,12 @@ export type AuthEncMessage = { * @param {BitGoBase} bitgo BitGo object * @return {Key} public gpg key */ -export async function getBitgoGpgPubKey( - bitgo: BitGoBase -): Promise<{ mpcV1: Key; mpcV2: Key | undefined; eddsaMpcV2: Key | undefined }> { +export async function getBitgoGpgPubKey(bitgo: BitGoBase): Promise<{ + mpcV1: Key; + mpcV2: Key | undefined; + eddsaMpcV2: Key | undefined; + redpallasMpcV2: Key | undefined; +}> { const constants = await bitgo.fetchConstants(); if (!constants.mpc || !constants.mpc.bitgoPublicKey) { throw new Error('Unable to create MPC keys - bitgoPublicKey is missing from constants'); @@ -50,10 +53,14 @@ export async function getBitgoGpgPubKey( const bitgoEddsaMpcv2PublicKeyStr = constants.mpc.bitgoEddsaMpcv2PublicKey ? await readKey({ armoredKey: constants.mpc.bitgoEddsaMpcv2PublicKey as string }) : undefined; + const bitgoRedpallasMpcv2PublicKeyStr = constants.mpc.bitgoRedpallasMpcv2PublicKey + ? await readKey({ armoredKey: constants.mpc.bitgoRedpallasMpcv2PublicKey as string }) + : undefined; return { mpcV1: await readKey({ armoredKey: bitgoPublicKeyStr }), mpcV2: bitgoMPCv2PublicKeyStr, eddsaMpcV2: bitgoEddsaMpcv2PublicKeyStr, + redpallasMpcV2: bitgoRedpallasMpcv2PublicKeyStr, }; } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index d4ee0a9c4f..7877136bfc 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -57,6 +57,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil protected bitgoPublicGpgKey: openpgp.Key; protected bitgoMPCv2PublicGpgKey: openpgp.Key | undefined; protected bitgoEddsaMpcv2PublicGpgKey: openpgp.Key | undefined; + protected bitgoRedpallasMpcv2PublicGpgKey: openpgp.Key | undefined; constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, wallet?: IWallet) { super(bitgo, baseCoin); @@ -71,7 +72,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil } protected async setBitgoGpgPubKey(bitgo) { - const { mpcV1, mpcV2, eddsaMpcV2 } = await getBitgoGpgPubKey(bitgo); + const { mpcV1, mpcV2, eddsaMpcV2, redpallasMpcV2 } = await getBitgoGpgPubKey(bitgo); // Do not unset the MPCv1 key if it is already set. This is to avoid unsetting if extra constants api calls fail. if (mpcV1 !== undefined) { this.bitgoPublicGpgKey = mpcV1; @@ -84,13 +85,18 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil if (eddsaMpcV2 !== undefined) { this.bitgoEddsaMpcv2PublicGpgKey = eddsaMpcV2; } + // Do not unset the RedPallas MPCv2 key if it is already set + if (redpallasMpcV2 !== undefined) { + this.bitgoRedpallasMpcv2PublicGpgKey = redpallasMpcV2; + } } public async pickBitgoPubGpgKeyForSigning( isMpcv2: boolean, reqId?: IRequestTracer, enterpriseId?: string, - isEddsaMpcv2?: boolean + isEddsaMpcv2?: boolean, + isRedpallasMpcv2?: boolean ): Promise { let bitgoGpgPubKey; try { @@ -102,7 +108,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil armoredKey: getBitgoMpcGpgPubKey( this.bitgo.getEnv(), bitgoKeyChain.hsmType === 'nitro' ? 'nitro' : 'onprem', - isEddsaMpcv2 ? 'eddsaMpcv2' : isMpcv2 ? 'mpcv2' : 'mpcv1' + isRedpallasMpcv2 ? 'redpallasMpcv2' : isEddsaMpcv2 ? 'eddsaMpcv2' : isMpcv2 ? 'mpcv2' : 'mpcv1' ), }); } catch (e) { @@ -112,7 +118,10 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil ); // First try to get the key based on feature flags, if that fails, fallback to the default key from constants api. bitgoGpgPubKey = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(enterpriseId, isMpcv2, reqId) - .then(async ({ mpcv2PublicKey, eddsaMpcv2PublicKey }) => { + .then(async ({ mpcv2PublicKey, eddsaMpcv2PublicKey, redpallasMpcv2PublicKey }) => { + if (isRedpallasMpcv2) { + return redpallasMpcv2PublicKey ?? (await this.getBitgoRedpallasMpcv2PublicGpgKey()); + } if (isEddsaMpcv2) { return eddsaMpcv2PublicKey ?? (await this.getBitgoEddsaMpcv2PublicGpgKey()); } @@ -121,6 +130,9 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil ); }) .catch(async () => { + if (isRedpallasMpcv2) { + return this.getBitgoRedpallasMpcv2PublicGpgKey(); + } if (isEddsaMpcv2) { return this.getBitgoEddsaMpcv2PublicGpgKey(); } @@ -171,6 +183,18 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil return this.bitgoEddsaMpcv2PublicGpgKey; } + async getBitgoRedpallasMpcv2PublicGpgKey(): Promise { + if (!this.bitgoRedpallasMpcv2PublicGpgKey) { + // retry getting bitgo's gpg key + await this.setBitgoGpgPubKey(this.bitgo); + if (!this.bitgoRedpallasMpcv2PublicGpgKey) { + throw new Error("Failed to get Bitgo's RedPallas MPCv2 gpg key"); + } + } + + return this.bitgoRedpallasMpcv2PublicGpgKey; + } + async createBitgoHeldBackupKeyShare( userGpgKey: SerializedKeyPair, enterprise: string | undefined @@ -598,7 +622,8 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil /** * It gets the appropriate BitGo GPG public keys for key creation based on a * combination of coin and the feature flags on the user and their enterprise if set. - * Returns both the default MPCv2 key and the EdDSA-specific MPCv2 key (if present). + * Returns the default MPCv2 key, the EdDSA-specific MPCv2 key, and the RedPallas-specific + * MPCv2 key (each if present). * @param enterpriseId - enterprise under which user wants to create the wallet * @param isMPCv2 - true to get the MPCv2 GPG public key, defaults to false * @param reqId - request tracer request id @@ -607,7 +632,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil enterpriseId: string | undefined, isMPCv2 = false, reqId?: IRequestTracer - ): Promise<{ mpcv2PublicKey: Key; eddsaMpcv2PublicKey: Key | undefined }> { + ): Promise<{ mpcv2PublicKey: Key; eddsaMpcv2PublicKey: Key | undefined; redpallasMpcv2PublicKey: Key | undefined }> { const reqTracer = reqId || new RequestTracer(); this.bitgo.setRequestTracer(reqTracer); const response: BitgoGPGPublicKey = await this.bitgo @@ -621,7 +646,10 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil const eddsaMpcv2PublicKey = response.eddsaMpcv2PublicKey ? await readKey({ armoredKey: response.eddsaMpcv2PublicKey }) : undefined; - return { mpcv2PublicKey, eddsaMpcv2PublicKey }; + const redpallasMpcv2PublicKey = response.redpallasMpcv2PublicKey + ? await readKey({ armoredKey: response.redpallasMpcv2PublicKey }) + : undefined; + return { mpcv2PublicKey, eddsaMpcv2PublicKey, redpallasMpcv2PublicKey }; } /** diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 96c4d14d88..5ad412fd8b 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -756,6 +756,7 @@ export interface BitgoGPGPublicKey { publicKey: string; mpcv2PublicKey?: string; eddsaMpcv2PublicKey?: string; + redpallasMpcv2PublicKey?: string; enterpriseId: string; } diff --git a/modules/sdk-core/test/unit/bitgo/tss/bitgoPubKeys.ts b/modules/sdk-core/test/unit/bitgo/tss/bitgoPubKeys.ts new file mode 100644 index 0000000000..315e5f4127 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/tss/bitgoPubKeys.ts @@ -0,0 +1,62 @@ +import * as assert from 'assert'; +import { + bitgoMpcGpgPubKeys, + getBitgoMpcGpgPubKey, + isBitgoEddsaMpcv2PubKey, + isBitgoRedpallasMpcv2PubKey, +} from '../../../../src/bitgo/tss/bitgoPubKeys'; + +describe('bitgoPubKeys RedPallas MPCv2 key config', function () { + it('getBitgoMpcGpgPubKey returns a key for redpallasMpcv2 across env/hsmType combos', function () { + assert.strictEqual( + getBitgoMpcGpgPubKey('test', 'nitro', 'redpallasMpcv2'), + bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test + ); + assert.strictEqual( + getBitgoMpcGpgPubKey('prod', 'nitro', 'redpallasMpcv2'), + bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.prod + ); + assert.strictEqual( + getBitgoMpcGpgPubKey('test', 'onprem', 'redpallasMpcv2'), + bitgoMpcGpgPubKeys.redpallasMpcv2.onprem.test + ); + assert.strictEqual( + getBitgoMpcGpgPubKey('prod', 'onprem', 'redpallasMpcv2'), + bitgoMpcGpgPubKeys.redpallasMpcv2.onprem.prod + ); + }); + + it('non-prod/non-adminProd envs default to the test key for redpallasMpcv2', function () { + assert.strictEqual( + getBitgoMpcGpgPubKey('staging', 'nitro', 'redpallasMpcv2'), + bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test + ); + assert.strictEqual( + getBitgoMpcGpgPubKey('adminTest', 'onprem', 'redpallasMpcv2'), + bitgoMpcGpgPubKeys.redpallasMpcv2.onprem.test + ); + }); + + it('adminProd resolves to the prod key for redpallasMpcv2', function () { + assert.strictEqual( + getBitgoMpcGpgPubKey('adminProd', 'nitro', 'redpallasMpcv2'), + bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.prod + ); + }); + + it('isBitgoRedpallasMpcv2PubKey identifies known redpallasMpcv2 keys', function () { + assert.strictEqual(isBitgoRedpallasMpcv2PubKey(bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test), true); + assert.strictEqual(isBitgoRedpallasMpcv2PubKey(bitgoMpcGpgPubKeys.redpallasMpcv2.onprem.prod), true); + }); + + it('isBitgoRedpallasMpcv2PubKey rejects keys from other mpc versions', function () { + assert.strictEqual(isBitgoRedpallasMpcv2PubKey(bitgoMpcGpgPubKeys.mpcv1.nitro.test), false); + assert.strictEqual(isBitgoRedpallasMpcv2PubKey('not-a-real-key'), false); + }); + + it('does not affect eddsaMpcv2 key lookups', function () { + assert.strictEqual(getBitgoMpcGpgPubKey('test', 'nitro', 'eddsaMpcv2'), bitgoMpcGpgPubKeys.eddsaMpcv2.nitro.test); + assert.strictEqual(isBitgoEddsaMpcv2PubKey(bitgoMpcGpgPubKeys.eddsaMpcv2.nitro.test), true); + assert.strictEqual(isBitgoEddsaMpcv2PubKey(bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test), true); // interim: same key material + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/redpallasBitgoGpgKey.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallasBitgoGpgKey.ts new file mode 100644 index 0000000000..a019fad7b0 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/redpallasBitgoGpgKey.ts @@ -0,0 +1,95 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { BitGoBase, IBaseCoin, BaseTssUtils as BaseTssUtilsNamespace } from '../../../../../src'; +import { bitgoMpcGpgPubKeys } from '../../../../../src/bitgo/tss/bitgoPubKeys'; + +const BaseTssUtils = BaseTssUtilsNamespace.default; + +class TestBaseTssUtils extends BaseTssUtils { + async setBitgoGpgPubKeyForTest(bitgo): Promise { + return this.setBitgoGpgPubKey(bitgo); + } + + getBitgoRedpallasMpcv2PublicGpgKeyForTest(): Promise { + return this.getBitgoRedpallasMpcv2PublicGpgKey(); + } + + getBitgoEddsaMpcv2PublicGpgKeyForTest(): Promise { + return this.getBitgoEddsaMpcv2PublicGpgKey(); + } +} + +describe('BaseTssUtils RedPallas MPCv2 BitGo GPG key', function () { + let utils: TestBaseTssUtils; + let mockBitGo: BitGoBase; + + beforeEach(function () { + mockBitGo = {} as unknown as BitGoBase; + utils = new TestBaseTssUtils(mockBitGo, {} as unknown as IBaseCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('setBitgoGpgPubKey populates bitgoRedpallasMpcv2PublicGpgKey from constants', async function () { + mockBitGo.fetchConstants = sinon.stub().resolves({ + mpc: { + bitgoPublicKey: bitgoMpcGpgPubKeys.mpcv1.nitro.test, + bitgoRedpallasMpcv2PublicKey: bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test, + }, + }); + + await utils.setBitgoGpgPubKeyForTest(mockBitGo); + const key = await utils.getBitgoRedpallasMpcv2PublicGpgKeyForTest(); + + assert.ok(key); + assert.strictEqual(key.armor(), bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test); + }); + + it('does not populate bitgoRedpallasMpcv2PublicGpgKey when constants omit it', async function () { + mockBitGo.fetchConstants = sinon.stub().resolves({ + mpc: { + bitgoPublicKey: bitgoMpcGpgPubKeys.mpcv1.nitro.test, + }, + }); + + await utils.setBitgoGpgPubKeyForTest(mockBitGo); + + await assert.rejects( + utils.getBitgoRedpallasMpcv2PublicGpgKeyForTest(), + /Failed to get Bitgo's RedPallas MPCv2 gpg key/ + ); + }); + + it('populating the RedPallas key does not clobber a separately-set EdDSA MPCv2 key', async function () { + mockBitGo.fetchConstants = sinon + .stub() + .onFirstCall() + .resolves({ + mpc: { + bitgoPublicKey: bitgoMpcGpgPubKeys.mpcv1.nitro.test, + bitgoEddsaMpcv2PublicKey: bitgoMpcGpgPubKeys.eddsaMpcv2.nitro.test, + }, + }) + .onSecondCall() + .resolves({ + mpc: { + bitgoPublicKey: bitgoMpcGpgPubKeys.mpcv1.nitro.test, + bitgoRedpallasMpcv2PublicKey: bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test, + }, + }); + + await utils.setBitgoGpgPubKeyForTest(mockBitGo); + const eddsaKey = await utils.getBitgoEddsaMpcv2PublicGpgKeyForTest(); + assert.strictEqual(eddsaKey.armor(), bitgoMpcGpgPubKeys.eddsaMpcv2.nitro.test); + + await utils.setBitgoGpgPubKeyForTest(mockBitGo); + const redpallasKey = await utils.getBitgoRedpallasMpcv2PublicGpgKeyForTest(); + assert.strictEqual(redpallasKey.armor(), bitgoMpcGpgPubKeys.redpallasMpcv2.nitro.test); + + // EdDSA key should still be set - it must not have been unset by the second call. + const eddsaKeyAgain = await utils.getBitgoEddsaMpcv2PublicGpgKeyForTest(); + assert.strictEqual(eddsaKeyAgain.armor(), bitgoMpcGpgPubKeys.eddsaMpcv2.nitro.test); + }); +});