diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 280a2c2a19..8718c63d6e 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -137,6 +137,7 @@ "@bitgo/utxo-lib": "^11.24.4", "@types/superagent": "^4.1.3", "bignumber.js": "^9.1.1", + "cbor-x": "1.5.9", "lodash": "^4.18.0", "openpgp": "5.11.3", "stellar-sdk": "^10.0.1", diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createKeychains.ts new file mode 100644 index 0000000000..3e0c016046 --- /dev/null +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createKeychains.ts @@ -0,0 +1,528 @@ +import * as assert from 'assert'; +import nock = require('nock'); +import * as openpgp from 'openpgp'; +import { decode } from 'cbor-x'; + +import { TestableBG, TestBitGo } from '@bitgo/sdk-test'; +import { AddKeychainOptions, BaseCoin, common, ECDSAUtils, Keychain, Wallet } from '@bitgo/sdk-core'; +import { DklsComms, DklsDkg, DklsTypes, DklsVrf } from '@bitgo/sdk-lib-mpc'; +import { + MPCv2KeyGenRound1Request, + MPCv2KeyGenRound1Response, + MPCv2KeyGenRound2Request, + MPCv2KeyGenRound2Response, + MPCv2KeyGenRound3Request, + MPCv2KeyGenRound3Response, +} from '@bitgo/public-types'; +import { NonEmptyString } from 'io-ts-types'; +import { BitGo, BitgoGPGPublicKey } from '../../../../../../src'; + +/** + * VRF DKG message blobs riding the MPCv2-R1/R2 keygen payloads. Mirrors the wire + * format produced by sdk-core's ecdsaVrfMPCv2.ts. + */ +interface VrfMessageTransfer { + from: number; + to?: number; + payload: string; // base64 +} + +function serializeVrfMessages(messages: DklsTypes.DeserializedMessages): string { + const transfers: VrfMessageTransfer[] = [ + ...messages.broadcastMessages.map((m) => ({ from: m.from, payload: Buffer.from(m.payload).toString('base64') })), + ...messages.p2pMessages.map((m) => ({ + from: m.from, + to: m.to, + payload: Buffer.from(m.payload).toString('base64'), + })), + ]; + return Buffer.from(JSON.stringify(transfers)).toString('base64'); +} + +function deserializeVrfMessages(blob: string, forParty: number): DklsTypes.DeserializedMessages { + const transfers: VrfMessageTransfer[] = JSON.parse(Buffer.from(blob, 'base64').toString()); + return { + broadcastMessages: transfers + .filter((t) => t.to === undefined) + .map((t) => ({ from: t.from, payload: new Uint8Array(Buffer.from(t.payload, 'base64')) })), + p2pMessages: transfers + .filter((t): t is VrfMessageTransfer & { to: number } => t.to === forParty) + .map((t) => ({ from: t.from, to: t.to, payload: new Uint8Array(Buffer.from(t.payload, 'base64')) })), + }; +} + +type VrfKeyGenRequestFields = { + userVrfMsg1?: string; + backupVrfMsg1?: string; + userVrfMsg2?: string; + backupVrfMsg2?: string; +}; + +type VrfKeyshareDecoded = { + threshold: number; + total_parties: number; + party_id: number; + d_i: Uint8Array; + public_key: Uint8Array; + party_public_shares: Uint8Array[]; + key_id: Uint8Array; + root_chain_code: Uint8Array; + final_session_id: Uint8Array; +}; +type VrfKeyGenResponseFields = { + bitgoVrfMsg1?: string; + bitgoVrfMsg2?: string; +}; + +describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { + const coinName = 'hteth'; + const enterpriseId = '6449153a6f6bc20006d66771cdbe15d3'; + const safeId = '6fa8537e3ef5a878fd3ae899f3ab7e5a'; + let storedUserCommitment2: string; + let storedBackupCommitment2: string; + let storedBitgoCommitment2: string; + let storedBitgoVrfOpenings: DklsTypes.DeserializedMessages | undefined; + + let bgUrl: string; + let tssUtils: ECDSAUtils.EcdsaVrfMPCv2Utils; + let wallet: Wallet; + let bitgo: TestableBG & BitGo; + let baseCoin: BaseCoin; + let bitgoVrfShare: VrfKeyshareDecoded | undefined; + let bitGoGgpKey: openpgp.SerializedKeyPair & { + revocationCertificate: string; + }; + let constants: { mpc: { bitgoPublicKey: string; bitgoMPCv2PublicKey: string } }; + let bitgoGpgPrvKey: { partyId: number; gpgKey: string }; + let userGpgPubKey: { partyId: number; gpgKey: string }; + let backupGpgPubKey: { partyId: number; gpgKey: string }; + + beforeEach(async function () { + nock.cleanAll(); + await nockGetBitgoPublicKeyBasedOnFeatureFlags(coinName, enterpriseId, bitGoGgpKey); + nock(bgUrl).get('/api/v1/client/constants').times(16).reply(200, { ttl: 3600, constants }); + }); + + before(async function () { + // Allow secp256k1 GPG keys used by these fixtures (the full suite enables this + // globally via sibling test files; set it here so this file also runs in isolation). + openpgp.config.rejectCurves = new Set(); + bitGoGgpKey = await openpgp.generateKey({ + userIDs: [ + { + name: 'bitgo', + email: 'bitgo@test.com', + }, + ], + curve: 'secp256k1', + }); + constants = { + mpc: { + bitgoPublicKey: bitGoGgpKey.publicKey, + bitgoMPCv2PublicKey: bitGoGgpKey.publicKey, + }, + }; + + bitgoGpgPrvKey = { + partyId: 2, + gpgKey: bitGoGgpKey.privateKey, + }; + + bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + + baseCoin = bitgo.coin(coinName); + + bgUrl = common.Environments[bitgo.getEnv()].uri; + + const walletData = { + id: '5b34252f1bf349930e34020a00000000', + enterprise: enterpriseId, + coin: coinName, + coinSpecific: {}, + multisigType: 'tss', + }; + wallet = new Wallet(bitgo, baseCoin, walletData); + tssUtils = new ECDSAUtils.EcdsaVrfMPCv2Utils(bitgo, baseCoin, wallet); + }); + + after(function () { + nock.cleanAll(); + }); + + it('should generate TSS MPCv2 keys with VRF keyshares for safe roots', async function () { + const bitgoSession = new DklsDkg.Dkg(3, 2, 2); + const bitgoVrfSession = new DklsVrf.VrfDkg(3, 2, 2); + + const round1Nock = await nockKeyGenRound1(bitgoSession, bitgoVrfSession); + const round2Nock = await nockKeyGenRound2(bitgoSession, bitgoVrfSession); + const round3Nock = await nockKeyGenRound3(bitgoSession, bitgoVrfSession); + const addKeyNock = await nockAddKeyChain(coinName, 3); + const params = { + passphrase: 'test', + enterprise: enterpriseId, + originalPasscodeEncryptionCode: '123456', + safeId, + }; + const { userKeychain, backupKeychain, bitgoKeychain } = await tssUtils.createKeychains(params); + assert.ok(round1Nock.isDone()); + assert.ok(round2Nock.isDone()); + assert.ok(round3Nock.isDone()); + assert.ok(addKeyNock.isDone()); + + assert.ok(userKeychain); + assert.equal(userKeychain.source, 'user'); + assert.ok(userKeychain.commonKeychain); + assert.ok(ECDSAUtils.EcdsaMPCv2Utils.validateCommonKeychainPublicKey(userKeychain.commonKeychain)); + assert.ok(userKeychain.encryptedPrv); + assert.ok(backupKeychain); + assert.ok(backupKeychain.encryptedPrv); + assert.ok(bitgoKeychain); + + // The VRF DKG must have completed on the (simulated) HSM side as well. + assert.ok(bitgoVrfShare, 'BitGo VRF keyshare was not produced'); + + assert.ok(userKeychain.encryptedPrv); + const decryptedUserPrv = await bitgo.decrypt({ input: userKeychain.encryptedPrv, password: params.passphrase }); + const userEnvelope = decode(Buffer.from(decryptedUserPrv, 'base64')); + assert.equal(userEnvelope.version, 1); + assert.ok(userEnvelope.prvKeyShare); + assert.ok(userEnvelope.vrf); + const userKeyShare = Buffer.from(userEnvelope.prvKeyShare); + assert.equal(DklsTypes.getCommonKeychain(userKeyShare), userKeychain.commonKeychain); + const userVrfShare = decode(userEnvelope.vrf); + assert.equal(userVrfShare.party_id, 0); + // All three parties' VRF DKGs agree on the VRF public key. + assert.equal( + Buffer.from(userVrfShare.public_key).toString('hex'), + Buffer.from(bitgoVrfShare.public_key).toString('hex') + ); + + assert.ok(backupKeychain.encryptedPrv); + const decryptedBackupPrv = await bitgo.decrypt({ + input: backupKeychain.encryptedPrv, + password: params.passphrase, + }); + const backupEnvelope = decode(Buffer.from(decryptedBackupPrv, 'base64')); + assert.equal(backupEnvelope.version, 1); + const backupVrfShare = decode(backupEnvelope.vrf); + assert.equal(backupVrfShare.party_id, 1); + assert.equal( + Buffer.from(backupVrfShare.public_key).toString('hex'), + Buffer.from(bitgoVrfShare.public_key).toString('hex') + ); + assert.notDeepStrictEqual(userVrfShare.d_i, backupVrfShare.d_i); + + // reducedEncryptedPrv takes the same envelope and carries VRF material. + const reducedEncryptedPrv = (userKeychain as Keychain & { reducedEncryptedPrv?: string }).reducedEncryptedPrv; + assert.ok(reducedEncryptedPrv); + const reducedDecrypted = await bitgo.decrypt({ input: reducedEncryptedPrv, password: params.passphrase }); + const reducedEnvelope = decode(Buffer.from(reducedDecrypted, 'base64')); + assert.equal(reducedEnvelope.version, 1); + const reducedKeyShare = DklsTypes.getDecodedReducedKeyShare(Buffer.from(reducedEnvelope.prvKeyShare)); + assert.ok(reducedKeyShare.prv.length > 0); + assert.ok(Buffer.from(reducedEnvelope.vrf).length > 0); + + // common keychains agree across parties + assert.equal(userKeychain.commonKeychain, backupKeychain.commonKeychain); + assert.equal(userKeychain.commonKeychain, bitgoKeychain.commonKeychain); + }); + + async function nockGetBitgoPublicKeyBasedOnFeatureFlags( + coin: string, + enterpriseId: string, + bitgoGpgKeyPair: openpgp.SerializedKeyPair + ): Promise { + const bitgoGPGPublicKeyResponse: BitgoGPGPublicKey = { + name: 'irrelevant', + publicKey: bitgoGpgKeyPair.publicKey, + mpcv2PublicKey: bitgoGpgKeyPair.publicKey, + enterpriseId, + }; + nock(bgUrl).get(`/api/v2/${coin}/tss/pubkey`).query({ enterpriseId }).reply(200, bitgoGPGPublicKeyResponse); + + return bitgoGPGPublicKeyResponse; + } + + async function nockKeyGenRound1(bitgoSession: DklsDkg.Dkg, bitgoVrfSession: DklsVrf.VrfDkg, times = 1) { + return nock(bgUrl) + .post(`/api/v2/mpc/generatekey`, (body) => body.round === 'MPCv2-R1') + .times(times) + .reply( + 200, + async ( + uri, + { payload }: { payload: MPCv2KeyGenRound1Request & VrfKeyGenRequestFields } + ): Promise => { + const { userGpgPublicKey, backupGpgPublicKey, userMsg1, backupMsg1, userVrfMsg1, backupVrfMsg1 } = payload; + assert.ok(userVrfMsg1, 'userVrfMsg1 must be present on a safe keygen round 1 payload'); + assert.ok(backupVrfMsg1, 'backupVrfMsg1 must be present on a safe keygen round 1 payload'); + userGpgPubKey = { + partyId: 0, + gpgKey: userGpgPublicKey, + }; + backupGpgPubKey = { + partyId: 1, + gpgKey: backupGpgPublicKey, + }; + + const bitgoBroadcastMsg1Unsigned = await bitgoSession.initDkg(); + const bitgoMsgs1Signed = await DklsComms.encryptAndAuthOutgoingMessages( + { broadcastMessages: [DklsTypes.serializeBroadcastMessage(bitgoBroadcastMsg1Unsigned)], p2pMessages: [] }, + [], + [bitgoGpgPrvKey] + ); + const bitgoMsg1 = bitgoMsgs1Signed.broadcastMessages.find((m) => m.from === 2); + assert.ok(bitgoMsg1, 'bitgoMsg1 not found'); + + const round1IncomingMsgs = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [ + { from: 0, payload: userMsg1 }, + { from: 1, payload: backupMsg1 }, + ], + }, + [userGpgPubKey, backupGpgPubKey], + [bitgoGpgPrvKey] + ); + + const round2Messages = DklsTypes.serializeMessages( + bitgoSession.handleIncomingMessages(DklsTypes.deserializeMessages(round1IncomingMsgs)) + ); + + const round2SignedMessages = await DklsComms.encryptAndAuthOutgoingMessages( + round2Messages, + [userGpgPubKey, backupGpgPubKey], + [bitgoGpgPrvKey] + ); + + const bitgoToUserMsg2 = round2SignedMessages.p2pMessages.find((m) => m.to === 0); + const bitgoToBackupMsg2 = round2SignedMessages.p2pMessages.find((m) => m.to === 1); + assert.ok(bitgoToUserMsg2, 'bitgoToUserMsg2 not found'); + assert.ok(bitgoToBackupMsg2, 'bitgoToBackupMsg2 not found'); + assert.ok(bitgoToUserMsg2.commitment, 'bitgoToUserMsg2.commitment not found'); + + storedBitgoCommitment2 = bitgoToUserMsg2?.commitment; + + // VRF DKG: BitGo commits, then consumes user and backup commitments. + const bitgoVrfMsg1Unsigned = await bitgoVrfSession.initDkg(); + const userVrfCommitments = deserializeVrfMessages(userVrfMsg1, 2); + const backupVrfCommitments = deserializeVrfMessages(backupVrfMsg1, 2); + assert.equal(userVrfCommitments.broadcastMessages.length, 1); + assert.equal(userVrfCommitments.broadcastMessages[0].from, 0); + assert.equal(backupVrfCommitments.broadcastMessages.length, 1); + assert.equal(backupVrfCommitments.broadcastMessages[0].from, 1); + storedBitgoVrfOpenings = await bitgoVrfSession.handleIncomingMessages({ + broadcastMessages: [...userVrfCommitments.broadcastMessages, ...backupVrfCommitments.broadcastMessages], + p2pMessages: [], + }); + // BitGo's openings are addressed to each party, itself included. + assert.equal(storedBitgoVrfOpenings.p2pMessages.length, 3); + + return { + sessionId: 'testid' as NonEmptyString, + bitgoMsg1: { from: 2, ...bitgoMsg1.payload }, + bitgoToBackupMsg2: { + from: 2, + to: 1, + encryptedMessage: bitgoToBackupMsg2.payload.encryptedMessage, + signature: bitgoToBackupMsg2.payload.signature, + }, + bitgoToUserMsg2: { + from: 2, + to: 0, + encryptedMessage: bitgoToUserMsg2.payload.encryptedMessage, + signature: bitgoToUserMsg2.payload.signature, + }, + walletGpgPubKeySigs: 'something' as NonEmptyString, + bitgoVrfMsg1: serializeVrfMessages(bitgoVrfMsg1Unsigned), + }; + } + ); + } + + async function nockKeyGenRound2(bitgoSession: DklsDkg.Dkg, bitgoVrfSession: DklsVrf.VrfDkg, times = 1) { + return nock(bgUrl) + .post(`/api/v2/mpc/generatekey`, (body) => body.round === 'MPCv2-R2') + .times(times) + .reply( + 200, + async ( + uri, + { payload }: { payload: MPCv2KeyGenRound2Request & VrfKeyGenRequestFields } + ): Promise => { + const { sessionId, userMsg2, backupMsg2, userCommitment2, backupCommitment2, userVrfMsg2, backupVrfMsg2 } = + payload; + assert.ok(userVrfMsg2, 'userVrfMsg2 must be present on a safe keygen round 2 payload'); + assert.ok(backupVrfMsg2, 'backupVrfMsg2 must be present on a safe keygen round 2 payload'); + storedUserCommitment2 = userCommitment2; + storedBackupCommitment2 = backupCommitment2; + const round2IncomingMsgs = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [ + { + from: userMsg2.from, + to: userMsg2.to, + payload: { signature: userMsg2.signature, encryptedMessage: userMsg2.encryptedMessage }, + }, + { + from: backupMsg2.from, + to: backupMsg2.to, + payload: { signature: backupMsg2.signature, encryptedMessage: backupMsg2.encryptedMessage }, + }, + ], + broadcastMessages: [], + }, + [userGpgPubKey, backupGpgPubKey], + [bitgoGpgPrvKey] + ); + + const round3Messages = DklsTypes.serializeMessages( + bitgoSession.handleIncomingMessages(DklsTypes.deserializeMessages(round2IncomingMsgs)) + ); + + const round3SignedMessages = await DklsComms.encryptAndAuthOutgoingMessages( + round3Messages, + [userGpgPubKey, backupGpgPubKey], + [bitgoGpgPrvKey] + ); + + const bitgoToUserMsg3 = round3SignedMessages.p2pMessages.find((m) => m.to === 0); + const bitgoToBackupMsg3 = round3SignedMessages.p2pMessages.find((m) => m.to === 1); + assert.ok(bitgoToUserMsg3, 'bitgoToUserMsg3 not found'); + assert.ok(bitgoToBackupMsg3, 'bitgoToBackupMsg3 not found'); + + // VRF DKG: BitGo consumes the openings addressed to it (its own included) and finalizes. + const userOpenings = deserializeVrfMessages(userVrfMsg2, 2).p2pMessages; + const backupOpenings = deserializeVrfMessages(backupVrfMsg2, 2).p2pMessages; + assert.ok(userOpenings.some((m) => m.from === 0 && m.to === 2)); + assert.ok(backupOpenings.some((m) => m.from === 1 && m.to === 2)); + assert.ok(storedBitgoVrfOpenings, 'BitGo VRF openings from round 1 not found'); + await bitgoVrfSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [ + ...userOpenings, + ...backupOpenings, + ...storedBitgoVrfOpenings.p2pMessages.filter((m) => m.to === 2), + ], + }); + const share = decode(bitgoVrfSession.getKeyShare()); + assert.equal(share.party_id, 2); + bitgoVrfShare = share; + + return { + sessionId, + bitgoCommitment2: storedBitgoCommitment2 as NonEmptyString, + bitgoToUserMsg3: { + from: 2, + to: 0, + encryptedMessage: bitgoToUserMsg3.payload.encryptedMessage, + signature: bitgoToUserMsg3.payload.signature, + }, + bitgoToBackupMsg3: { + from: 2, + to: 1, + encryptedMessage: bitgoToBackupMsg3.payload.encryptedMessage, + signature: bitgoToBackupMsg3.payload.signature, + }, + // BitGo's full opening batch, so user (0) and backup (1) can each pick theirs. + bitgoVrfMsg2: serializeVrfMessages(storedBitgoVrfOpenings), + }; + } + ); + } + + async function nockKeyGenRound3(bitgoSession: DklsDkg.Dkg, bitgoVrfSession: DklsVrf.VrfDkg, times = 1) { + return nock(bgUrl) + .post(`/api/v2/mpc/generatekey`, (body) => body.round === 'MPCv2-R3') + .times(times) + .reply( + 200, + async (uri, { payload }: { payload: MPCv2KeyGenRound3Request }): Promise => { + const { sessionId, userMsg3, userMsg4, backupMsg3, backupMsg4 } = payload; + // The VRF DKG has no messages on round 3; it already finalized after round 2. + assert.ok(bitgoVrfShare, 'BitGo VRF keyshare should be complete before round 3'); + + const round3IncomingMsgs = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [ + { + from: userMsg3.from, + to: userMsg3.to, + payload: { signature: userMsg3.signature, encryptedMessage: userMsg3.encryptedMessage }, + commitment: storedUserCommitment2, + }, + { + from: backupMsg3.from, + to: backupMsg3.to, + payload: { signature: backupMsg3.signature, encryptedMessage: backupMsg3.encryptedMessage }, + commitment: storedBackupCommitment2, + }, + ], + broadcastMessages: [], + }, + [userGpgPubKey, backupGpgPubKey], + [bitgoGpgPrvKey] + ); + + const round4Messages = DklsTypes.serializeMessages( + bitgoSession.handleIncomingMessages(DklsTypes.deserializeMessages(round3IncomingMsgs)) + ); + const round4SignedMessages = await DklsComms.encryptAndAuthOutgoingMessages( + round4Messages, + [], + [bitgoGpgPrvKey] + ); + const bitgoMsg4 = round4SignedMessages.broadcastMessages.find((m) => m.from === 2); + assert.ok(bitgoMsg4, 'bitgoMsg4 not found'); + + const round4IncomingMsgs = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [ + { + from: userMsg4.from, + payload: { signature: userMsg4.signature, message: userMsg4.message }, + }, + { + from: backupMsg4.from, + + payload: { signature: backupMsg4.signature, message: backupMsg4.message }, + }, + ], + }, + [userGpgPubKey, backupGpgPubKey], + [] + ); + bitgoSession.handleIncomingMessages(DklsTypes.deserializeMessages(round4IncomingMsgs)); + const keyShare = bitgoSession.getKeyShare(); + const commonKeychain = DklsTypes.getCommonKeychain(keyShare); + + return { + sessionId, + commonKeychain: commonKeychain as NonEmptyString, + bitgoMsg4: { from: 2, ...bitgoMsg4.payload }, + }; + } + ); + } + + async function nockAddKeyChain(coin: string, times = 1) { + return nock('https://bitgo.fakeurl') + .post(`/api/v2/${coin}/key`, (body) => body.keyType === 'tss' && body.isMPCv2 && body.safeId === safeId) + .times(times) + .reply(200, async (uri, requestBody: AddKeychainOptions) => { + const key = { + id: requestBody.source, + source: requestBody.source, + type: requestBody.keyType, + commonKeychain: requestBody.commonKeychain, + encryptedPrv: requestBody.encryptedPrv, + }; + // nock gets + nock('https://bitgo.fakeurl').get(`/api/v2/${coin}/key/${requestBody.source}`).reply(200, key); + return key; + }); + } +}); diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 2be7a6532b..18fd5d2631 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -44,6 +44,7 @@ "@bitgo/sdk-lib-mpc": "^10.18.0", "@bitgo/secp256k1": "^1.11.1", "@bitgo/sjcl": "^1.1.0", + "cbor-x": "1.5.9", "@bitgo/statics": "^59.12.0", "@bitgo/utxo-lib": "^11.24.4", "@noble/curves": "1.8.1", diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 26b5177fb0..06fff59405 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -414,7 +414,14 @@ export class Keychains implements IKeychains { if (this.baseCoin.getMPCAlgorithm() === 'eddsa') { MpcUtils = isMPCv2 ? EDDSAUtils.EddsaMPCv2Utils : EDDSAUtils.default; } else { - MpcUtils = isMPCv2 ? ECDSAUtils.EcdsaMPCv2Utils : ECDSAUtils.EcdsaUtils; + // A safeId on a keygen ceremony selects the VRF variant, which additionally runs the + // VRF DKG alongside the signing DKG. Ordinary TSS wallet creation never sets safeId + // and keeps the plain MPCv2 flow. + MpcUtils = isMPCv2 + ? params.safeId + ? ECDSAUtils.EcdsaVrfMPCv2Utils + : ECDSAUtils.EcdsaMPCv2Utils + : ECDSAUtils.EcdsaUtils; } const mpcUtils = new MpcUtils(this.bitgo, this.baseCoin); diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts new file mode 100644 index 0000000000..e0a1ca5b47 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts @@ -0,0 +1,549 @@ +import { DklsComms, DklsDkg, DklsTypes, DklsVrf } from '@bitgo/sdk-lib-mpc'; +import { encode } from 'cbor-x'; +import assert from 'assert'; +import { NonEmptyString } from 'io-ts-types'; +import { MPCv2KeyGenRound1Response, MPCv2KeyGenRound2Response, MPCv2KeyGenStateEnum } from '@bitgo/public-types'; + +import { KeychainsTriplet } from '../../../baseCoin'; +import { DecryptedRetrofitPayload } from '../../../keychain/iKeychains'; +import { EncryptionVersion } from '../../../../api'; +import { generateGPGKeyPair } from '../../opengpgUtils'; +import { WebauthnKeyEncryptionInfo } from '../../../keychain'; +import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys'; +import { EcdsaMPCv2Utils } from './ecdsaMPCv2'; +import { KeyGenSenderForEnterprise } from './ecdsaMPCv2KeyGenSender'; +import { MPCv2PartiesEnum, MpcV2VrfKeyGenResponseFields } from './typesMPCv2'; + +/** + * Version field of the `encryptedPrv` envelope used when a ceremony produces both a + * signing keyshare and a VRF keyshare. The plaintext handed to encrypt() is + * `base64(cborEncode(envelope))`, keeping it a single opaque base64 token exactly as + * the ordinary MPCv2 format does. + */ +const VRF_KEY_ENVELOPE_VERSION = 1; + +/** + * Wire format for VRF DKG messages riding the MPCv2-R1/R2 payloads: an opaque blob, + * `base64(JSON.stringify(VrfMessageTransfer[]))`, carrying the party's full batch of + * emitted messages. The VRF DKG emits broadcast commitments (R1) and per-recipient + * opening messages addressed to each party including the sender (R2), so the receiving + * side routes by `to` and keeps messages addressed to itself. + */ +interface VrfMessageTransfer { + from: number; + to?: number; + payload: string; // base64 +} + +function serializeVrfMessages(messages: DklsTypes.DeserializedMessages): string { + const transfers: VrfMessageTransfer[] = [ + ...messages.broadcastMessages.map((m) => ({ from: m.from, payload: Buffer.from(m.payload).toString('base64') })), + ...messages.p2pMessages.map((m) => ({ + from: m.from, + to: m.to, + payload: Buffer.from(m.payload).toString('base64'), + })), + ]; + return Buffer.from(JSON.stringify(transfers)).toString('base64'); +} + +function deserializeVrfMessages(blob: string, forParty: number): DklsTypes.DeserializedMessages { + const transfers: VrfMessageTransfer[] = JSON.parse(Buffer.from(blob, 'base64').toString()); + return { + broadcastMessages: transfers + .filter((t) => t.to === undefined) + .map((t) => ({ from: t.from, payload: new Uint8Array(Buffer.from(t.payload, 'base64')) })), + p2pMessages: transfers + .filter((t): t is VrfMessageTransfer & { to: number } => t.to === forParty) + .map((t) => ({ from: t.from, to: t.to, payload: new Uint8Array(Buffer.from(t.payload, 'base64')) })), + }; +} + +/** + * Combines the signing keyshare with the VRF keyshare into the `encryptedPrv` envelope + * (`base64(cborEncode({version, prvKeyShare, vrf}))` once the caller base64-encodes the + * returned buffers). The same container wraps the reduced signing share for + * `reducedEncryptedPrv`. + */ +export function buildVrfKeyEnvelopes( + privateMaterial: Buffer, + reducedPrivateMaterial: Buffer, + vrfKeyShare: Buffer +): { envelope: Buffer; reducedEnvelope: Buffer } { + const envelope = encode({ + version: VRF_KEY_ENVELOPE_VERSION, + prvKeyShare: new Uint8Array(privateMaterial), + vrf: new Uint8Array(vrfKeyShare), + }); + const reducedEnvelope = encode({ + version: VRF_KEY_ENVELOPE_VERSION, + prvKeyShare: new Uint8Array(reducedPrivateMaterial), + vrf: new Uint8Array(vrfKeyShare), + }); + return { envelope: Buffer.from(envelope), reducedEnvelope: Buffer.from(reducedEnvelope) }; +} + +/** + * EcdsaMPCv2Utils variant that runs the Ristretto VRF DKG alongside the signing DKLS + * DKG inside the same MPCv2 keygen rounds, for safe MPC root creation. + * + * The round enum is unchanged: the VRF commitment rides MPCv2-R1, the opening rides + * MPCv2-R2, and the VRF DKG finalizes locally before MPCv2-R3. VRF messages travel as + * opaque base64 fields on the round payloads, never through the DKLS broadcast/p2p slots. + * At the end, the signing and VRF keyshares are combined into a single CBOR envelope + * before encryption. + */ +export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { + /** @inheritdoc */ + async createKeychains(params: { + passphrase: string; + enterprise: string; + originalPasscodeEncryptionCode?: string; + retrofit?: DecryptedRetrofitPayload; + webauthnInfo?: WebauthnKeyEncryptionInfo; + encryptionVersion?: EncryptionVersion; + // Tags the resulting user/backup/bitgo root keys with this safe. + safeId?: string; + }): Promise { + // This variant always runs the VRF DKG, so it must never be driven without a safe. + assert(params.safeId, 'safeId is required for VRF MPC root creation'); + const { userSession, backupSession } = this.getUserAndBackupSessions(params.retrofit); + const userVrfSession = new DklsVrf.VrfDkg(3, 2, MPCv2PartiesEnum.USER); + const backupVrfSession = new DklsVrf.VrfDkg(3, 2, MPCv2PartiesEnum.BACKUP); + + const userGpgKey = await generateGPGKeyPair('secp256k1'); + const backupGpgKey = await generateGPGKeyPair('secp256k1'); + + // Get the BitGo public key based on user/enterprise feature flags + // If it doesn't work, use the default public key from the constants + const { mpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true); + const mpcv2Key = mpcv2PublicKey ?? this.bitgoMPCv2PublicGpgKey; + assert(mpcv2Key, 'Failed to get BitGo MPCv2 GPG public key'); + const bitgoPublicGpgKey = mpcv2Key.armor(); + + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + // Ensure the public key is one of the expected BitGo public keys when in test or prod. + assert(isBitgoMpcPubKey(bitgoPublicGpgKey, 'mpcv2'), 'Invalid BitGo GPG public key'); + } + + const userGpgPrvKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.USER, + gpgKey: userGpgKey.privateKey, + }; + const backupGpgPrvKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.BACKUP, + gpgKey: backupGpgKey.privateKey, + }; + const bitgoGpgPubKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.BITGO, + gpgKey: bitgoPublicGpgKey, + }; + + // #region round 1 + const userRound1BroadcastMsg = await userSession.initDkg(); + const backupRound1BroadcastMsg = await backupSession.initDkg(); + const userVrfRound1Msg = await userVrfSession.initDkg(); + const backupVrfRound1Msg = await backupVrfSession.initDkg(); + + const round1SerializedMessages = DklsTypes.serializeMessages({ + broadcastMessages: [userRound1BroadcastMsg, backupRound1BroadcastMsg], + p2pMessages: [], + }); + const round1Messages = await DklsComms.encryptAndAuthOutgoingMessages( + round1SerializedMessages, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + const userMsg1 = round1Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; + const backupMsg1 = round1Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP)?.payload; + assert(userMsg1, 'User message 1 not found in broadcast messages'); + assert(backupMsg1, 'Backup message 1 not found in broadcast messages'); + + const userGpgPublicKey = userGpgKey.publicKey; + const backupGpgPublicKey = backupGpgKey.publicKey; + assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); + assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); + // The platform derives withVrf from the safeId on this request; the VRF messages ride as opaque blobs. + const round1Sender = KeyGenSenderForEnterprise( + this.bitgo, + params.enterprise, + params.safeId + ); + const { sessionId, bitgoMsg1, bitgoToBackupMsg2, bitgoToUserMsg2, bitgoVrfMsg1 } = await round1Sender( + MPCv2KeyGenStateEnum['MPCv2-R1'], + { + userMsg1: { from: MPCv2PartiesEnum.USER, ...userMsg1 }, + backupMsg1: { from: MPCv2PartiesEnum.BACKUP, ...backupMsg1 }, + userGpgPublicKey, + backupGpgPublicKey, + userVrfMsg1: serializeVrfMessages(userVrfRound1Msg), + backupVrfMsg1: serializeVrfMessages(backupVrfRound1Msg), + walletId: params.retrofit?.walletId, + } + ); + assert(bitgoVrfMsg1, 'BitGo VRF message 1 not found in round 1 response'); + // #endregion + + // #region round 2 + const bitgoRound1BroadcastMessages = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoMsg1)] }, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + const bitgoRound1BroadcastMsg = bitgoRound1BroadcastMessages.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO + ); + assert(bitgoRound1BroadcastMsg, 'BitGo message 1 not found in broadcast messages'); + + const userRound2P2PMessages = userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [DklsTypes.deserializeBroadcastMessage(bitgoRound1BroadcastMsg), backupRound1BroadcastMsg], + }); + + const userToBitgoMsg2 = userRound2P2PMessages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BITGO + ); + assert(userToBitgoMsg2, 'User message 2 not found in P2P messages'); + const serializedUserToBitgoMsg2 = DklsTypes.serializeP2PMessage(userToBitgoMsg2); + + const backupRound2P2PMessages = backupSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [userRound1BroadcastMsg, DklsTypes.deserializeBroadcastMessage(bitgoRound1BroadcastMsg)], + }); + const serializedBackupToBitgoMsg2 = DklsTypes.serializeMessages(backupRound2P2PMessages).p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + ); + assert(serializedBackupToBitgoMsg2, 'Backup message 2 not found in P2P messages'); + + // VRF: consume BitGo's and the counterparty's commitments, emit this party's openings. + // Openings come back as p2p messages addressed to each party (including the sender). + const userVrfRound2Messages = await userVrfSession.handleIncomingMessages({ + broadcastMessages: [ + ...backupVrfRound1Msg.broadcastMessages, + ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.USER).broadcastMessages, + ], + p2pMessages: [], + }); + + const backupVrfRound2Messages = await backupVrfSession.handleIncomingMessages({ + broadcastMessages: [ + ...userVrfRound1Msg.broadcastMessages, + ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.BACKUP).broadcastMessages, + ], + p2pMessages: [], + }); + + const round2Messages = await DklsComms.encryptAndAuthOutgoingMessages( + { p2pMessages: [serializedUserToBitgoMsg2, serializedBackupToBitgoMsg2], broadcastMessages: [] }, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + const userRound2Msg = round2Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BITGO + ); + const backupRound2Msg = round2Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + ); + assert(userRound2Msg, 'User to Bitgo message 2 not found in P2P messages'); + assert(userRound2Msg.commitment, 'User to Bitgo commitment not found in P2P messages'); + assert(backupRound2Msg, 'Backup to Bitgo message 2 not found in P2P messages'); + assert(backupRound2Msg.commitment, 'Backup to Bitgo commitment not found in P2P messages'); + assert(NonEmptyString.is(userRound2Msg.commitment), 'User to Bitgo commitment is required'); + assert(NonEmptyString.is(backupRound2Msg.commitment), 'Backup to Bitgo commitment is required'); + const round2Sender = KeyGenSenderForEnterprise( + this.bitgo, + params.enterprise + ); + const { + sessionId: sessionIdRound2, + bitgoCommitment2, + bitgoToUserMsg3, + bitgoToBackupMsg3, + bitgoVrfMsg2, + } = await round2Sender(MPCv2KeyGenStateEnum['MPCv2-R2'], { + sessionId, + userMsg2: { + from: MPCv2PartiesEnum.USER, + to: MPCv2PartiesEnum.BITGO, + signature: userRound2Msg.payload.signature, + encryptedMessage: userRound2Msg.payload.encryptedMessage, + }, + userCommitment2: userRound2Msg.commitment, + backupMsg2: { + from: MPCv2PartiesEnum.BACKUP, + to: MPCv2PartiesEnum.BITGO, + signature: backupRound2Msg.payload.signature, + encryptedMessage: backupRound2Msg.payload.encryptedMessage, + }, + backupCommitment2: backupRound2Msg.commitment, + userVrfMsg2: serializeVrfMessages(userVrfRound2Messages), + backupVrfMsg2: serializeVrfMessages(backupVrfRound2Messages), + }); + // #endregion + + // #region round 3 + assert.equal(sessionId, sessionIdRound2, 'Round 1 and 2 Session IDs do not match'); + assert(bitgoVrfMsg2, 'BitGo VRF message 2 not found in round 2 response'); + + // VRF: finalize both DKGs locally by consuming the opening messages addressed to each + // party (including each party's own) from the R2 exchanges. The VRF DKG completes here, + // a full round of slack before MPCv2-R3. + await userVrfSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [ + ...userVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.USER), + ...backupVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.USER), + ...deserializeVrfMessages(bitgoVrfMsg2, MPCv2PartiesEnum.USER).p2pMessages, + ], + }); + await backupVrfSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [ + ...userVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.BACKUP), + ...backupVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.BACKUP), + ...deserializeVrfMessages(bitgoVrfMsg2, MPCv2PartiesEnum.BACKUP).p2pMessages, + ], + }); + + const decryptedBitgoToUserRound2Msgs = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [this.formatP2PMessage(bitgoToUserMsg2)], broadcastMessages: [] }, + [bitgoGpgPubKey], + [userGpgPrvKey] + ); + const serializedBitgoToUserRound2Msg = decryptedBitgoToUserRound2Msgs.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.USER + ); + assert(serializedBitgoToUserRound2Msg, 'BitGo to User message 2 not found in P2P messages'); + const bitgoToUserRound2Msg = DklsTypes.deserializeP2PMessage(serializedBitgoToUserRound2Msg); + + const decryptedBitgoToBackupRound2Msg = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [this.formatP2PMessage(bitgoToBackupMsg2)], broadcastMessages: [] }, + [bitgoGpgPubKey], + [backupGpgPrvKey] + ); + const serializedBitgoToBackupRound2Msg = decryptedBitgoToBackupRound2Msg.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.BACKUP + ); + assert(serializedBitgoToBackupRound2Msg, 'BitGo to Backup message 2 not found in P2P messages'); + const bitgoToBackupRound2Msg = DklsTypes.deserializeP2PMessage(serializedBitgoToBackupRound2Msg); + + const userToBackupMsg2 = userRound2P2PMessages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BACKUP + ); + assert(userToBackupMsg2, 'User to Backup message 2 not found in P2P messages'); + + const backupToUserMsg2 = backupRound2P2PMessages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.USER + ); + assert(backupToUserMsg2, 'Backup to User message 2 not found in P2P messages'); + + const userRound3Messages = userSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToUserRound2Msg, backupToUserMsg2], + }); + const userToBackupMsg3 = userRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BACKUP + ); + assert(userToBackupMsg3, 'User to Backup message 3 not found in P2P messages'); + const userToBitgoMsg3 = userRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BITGO + ); + assert(userToBitgoMsg3, 'User to Bitgo message 3 not found in P2P messages'); + const serializedUserToBitgoMsg3 = DklsTypes.serializeP2PMessage(userToBitgoMsg3); + + const backupRound3Messages = backupSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToBackupRound2Msg, userToBackupMsg2], + }); + + const backupToUserMsg3 = backupRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.USER + ); + assert(backupToUserMsg3, 'Backup to User message 3 not found in P2P messages'); + const backupToBitgoMsg3 = backupRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + ); + assert(backupToBitgoMsg3, 'Backup to Bitgo message 3 not found in P2P messages'); + const serializedBackupToBitgoMsg3 = DklsTypes.serializeP2PMessage(backupToBitgoMsg3); + + const decryptedBitgoToUserRound3Messages = await DklsComms.decryptAndVerifyIncomingMessages( + { broadcastMessages: [], p2pMessages: [this.formatP2PMessage(bitgoToUserMsg3, bitgoCommitment2)] }, + [bitgoGpgPubKey], + [userGpgPrvKey] + ); + const serializedBitgoToUserRound3Msg = decryptedBitgoToUserRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.USER + ); + assert(serializedBitgoToUserRound3Msg, 'BitGo to User message 3 not found in P2P messages'); + const bitgoToUserRound3Msg = DklsTypes.deserializeP2PMessage(serializedBitgoToUserRound3Msg); + + const decryptedBitgoToBackupRound3Messages = await DklsComms.decryptAndVerifyIncomingMessages( + { broadcastMessages: [], p2pMessages: [this.formatP2PMessage(bitgoToBackupMsg3, bitgoCommitment2)] }, + [bitgoGpgPubKey], + [backupGpgPrvKey] + ); + const serializedBitgoToBackupRound3Msg = decryptedBitgoToBackupRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.BACKUP + ); + assert(serializedBitgoToBackupRound3Msg, 'BitGo to Backup message 3 not found in P2P messages'); + const bitgoToBackupRound3Msg = DklsTypes.deserializeP2PMessage(serializedBitgoToBackupRound3Msg); + + const userRound4Messages = userSession.handleIncomingMessages({ + p2pMessages: [backupToUserMsg3, bitgoToUserRound3Msg], + broadcastMessages: [], + }); + + const userRound4BroadcastMsg = userRound4Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER); + assert(userRound4BroadcastMsg, 'User message 4 not found in broadcast messages'); + const serializedUserRound4BroadcastMsg = DklsTypes.serializeBroadcastMessage(userRound4BroadcastMsg); + + const backupRound4Messages = backupSession.handleIncomingMessages({ + p2pMessages: [userToBackupMsg3, bitgoToBackupRound3Msg], + broadcastMessages: [], + }); + const backupRound4BroadcastMsg = backupRound4Messages.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP + ); + assert(backupRound4BroadcastMsg, 'Backup message 4 not found in broadcast messages'); + const serializedBackupRound4BroadcastMsg = DklsTypes.serializeBroadcastMessage(backupRound4BroadcastMsg); + + const round3Messages = await DklsComms.encryptAndAuthOutgoingMessages( + { + p2pMessages: [serializedUserToBitgoMsg3, serializedBackupToBitgoMsg3], + broadcastMessages: [serializedUserRound4BroadcastMsg, serializedBackupRound4BroadcastMsg], + }, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + + const { + sessionId: sessionIdRound3, + bitgoMsg4, + commonKeychain: bitgoCommonKeychain, + } = await this.sendKeyGenerationRound3(params.enterprise, sessionId, round3Messages); + + // #endregion + + // #region keychain creation + assert.equal(sessionId, sessionIdRound3, 'Round 1 and 3 Session IDs do not match'); + const bitgoRound4BroadcastMessages = DklsTypes.deserializeMessages( + await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoMsg4)] }, + [bitgoGpgPubKey], + [] + ) + ).broadcastMessages; + const bitgoRound4BroadcastMsg = bitgoRound4BroadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BITGO); + + assert(bitgoRound4BroadcastMsg, 'BitGo message 4 not found in broadcast messages'); + userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoRound4BroadcastMsg, backupRound4BroadcastMsg], + }); + + backupSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoRound4BroadcastMsg, userRound4BroadcastMsg], + }); + + const userPrivateMaterial = userSession.getKeyShare(); + const backupPrivateMaterial = backupSession.getKeyShare(); + const userReducedPrivateMaterial = userSession.getReducedKeyShare(); + const backupReducedPrivateMaterial = backupSession.getReducedKeyShare(); + const userVrfKeyShare = userVrfSession.getKeyShare(); + const backupVrfKeyShare = backupVrfSession.getKeyShare(); + + const userCommonKeychain = DklsTypes.getCommonKeychain(userPrivateMaterial); + const backupCommonKeychain = DklsTypes.getCommonKeychain(backupPrivateMaterial); + + assert.equal(bitgoCommonKeychain, userCommonKeychain, 'User and Bitgo Common keychains do not match'); + assert.equal(bitgoCommonKeychain, backupCommonKeychain, 'Backup and Bitgo Common keychains do not match'); + + const { envelope: userEnvelope, reducedEnvelope: userReducedEnvelope } = buildVrfKeyEnvelopes( + userPrivateMaterial, + userReducedPrivateMaterial, + userVrfKeyShare + ); + const { envelope: backupEnvelope, reducedEnvelope: backupReducedEnvelope } = buildVrfKeyEnvelopes( + backupPrivateMaterial, + backupReducedPrivateMaterial, + backupVrfKeyShare + ); + + const encryptionSession = + params.encryptionVersion === 2 ? await this.bitgo.createEncryptionSession(params.passphrase) : undefined; + try { + const userKeychainPromise = this.createParticipantKeychain( + MPCv2PartiesEnum.USER, + bitgoCommonKeychain, + userEnvelope, + userReducedEnvelope, + params.passphrase, + params.originalPasscodeEncryptionCode, + params.webauthnInfo, + encryptionSession, + params.encryptionVersion, + params.enterprise, + params.safeId + ); + const backupKeychainPromise = this.createParticipantKeychain( + MPCv2PartiesEnum.BACKUP, + bitgoCommonKeychain, + backupEnvelope, + backupReducedEnvelope, + params.passphrase, + params.originalPasscodeEncryptionCode, + undefined, + encryptionSession, + params.encryptionVersion, + undefined, + params.safeId + ); + const bitgoKeychainPromise = this.createParticipantKeychain( + MPCv2PartiesEnum.BITGO, + bitgoCommonKeychain, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + params.safeId + ); + + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + userKeychainPromise, + backupKeychainPromise, + bitgoKeychainPromise, + ]); + // #endregion + + return { + userKeychain, + backupKeychain, + bitgoKeychain, + }; + } finally { + encryptionSession?.destroy(); + } + } + + private getUserAndBackupSessions(retrofit?: DecryptedRetrofitPayload) { + if (retrofit) { + const retrofitData = this.getMpcV2RetrofitDataFromMpcV1Keys({ + mpcv1UserKeyShare: retrofit.decryptedUserKey, + mpcv1BackupKeyShare: retrofit.decryptedBackupKey, + }); + return { + userSession: new DklsDkg.Dkg(3, 2, MPCv2PartiesEnum.USER, undefined, retrofitData.mpcv2UserKeyShare), + backupSession: new DklsDkg.Dkg(3, 2, MPCv2PartiesEnum.BACKUP, undefined, retrofitData.mpcv2BackupKeyShare), + }; + } + return { + userSession: new DklsDkg.Dkg(3, 2, MPCv2PartiesEnum.USER), + backupSession: new DklsDkg.Dkg(3, 2, MPCv2PartiesEnum.BACKUP), + }; + } +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts index 0c50dbe0e3..bf08dc843a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts @@ -1,5 +1,6 @@ export * from './ecdsa'; export * from './ecdsaMPCv2'; +export * from './ecdsaVrfMPCv2'; export * from './types'; export * from './typesMPCv2'; export * from './SMC/utils'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts index 399ece951b..57b0938aa6 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts @@ -20,12 +20,30 @@ export const generateMPCv2KeyRequestBody = t.union([ MPCv2KeyGenRound3Request, ]); -export type GenerateMPCv2KeyRequestBody = t.TypeOf; - export const generateMPCv2KeyRequestResponse = t.union([ MPCv2KeyGenRound1Response, MPCv2KeyGenRound2Response, MPCv2KeyGenRound3Response, ]); -export type GenerateMPCv2KeyRequestResponse = t.TypeOf; +/** + * Optional VRF DKG message fields carried on the MPCv2-R1/R2 keygen round payloads as + * opaque base64 blobs. Only set when the ceremony runs the VRF DKG alongside the + * signing DKG (safe root keys); ordinary TSS wallet creation payloads are unchanged. + */ +export type MpcV2VrfKeyGenRequestFields = { + userVrfMsg1?: string; + backupVrfMsg1?: string; + userVrfMsg2?: string; + backupVrfMsg2?: string; +}; + +export type MpcV2VrfKeyGenResponseFields = { + bitgoVrfMsg1?: string; + bitgoVrfMsg2?: string; +}; + +export type GenerateMPCv2KeyRequestBody = t.TypeOf & MpcV2VrfKeyGenRequestFields; + +export type GenerateMPCv2KeyRequestResponse = t.TypeOf & + MpcV2VrfKeyGenResponseFields; diff --git a/modules/sdk-lib-mpc/src/tss/dkls-vrf/dkg.ts b/modules/sdk-lib-mpc/src/tss/dkls-vrf/dkg.ts index 2173488e99..a186e0bad7 100644 --- a/modules/sdk-lib-mpc/src/tss/dkls-vrf/dkg.ts +++ b/modules/sdk-lib-mpc/src/tss/dkls-vrf/dkg.ts @@ -194,8 +194,8 @@ export class VrfDkg { .filter((m) => m.to_id === undefined) .map((m) => ({ payload: new Uint8Array(m.payload), from: m.from_id })); nextRoundDeserializedMessages.p2pMessages = nextRoundMessages - .filter((m) => m.to_id !== undefined) - .map((m) => ({ payload: new Uint8Array(m.payload), from: m.from_id, to: m.to_id! })); + .filter((m): m is VrfWasmMessage & { to_id: number } => m.to_id !== undefined) + .map((m) => ({ payload: new Uint8Array(m.payload), from: m.from_id, to: m.to_id })); return nextRoundDeserializedMessages; } catch (e) { throw Error(`Error while creating VRF messages from party ${this.partyIdx}, state ${this.vrfState}: ${e}`);