From 3fb0f1557503e0edb706ddfe277292b2ef487665 Mon Sep 17 00:00:00 2001 From: Sibi Krishnan Date: Thu, 15 Oct 2026 14:34:07 -0400 Subject: [PATCH] feat: align DKLS safe VRF keygen with the HSM Ticket: WCN-3084 --- modules/abstract-lightning/package.json | 2 +- modules/bitgo/package.json | 2 +- .../tssUtils/ecdsaVrfMPCv2/createKeychains.ts | 187 +++++++++++------- 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 +- .../bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts | 146 ++++++++------ .../src/bitgo/utils/tss/ecdsa/typesMPCv2.ts | 22 +-- modules/sdk-lib-safes/package.json | 2 +- yarn.lock | 8 +- 12 files changed, 218 insertions(+), 161 deletions(-) diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index 5e6c64ba113..b9b46ff094e 100644 --- a/modules/abstract-lightning/package.json +++ b/modules/abstract-lightning/package.json @@ -39,7 +39,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/sdk-core": "^39.1.0", "@bitgo/statics": "^59.23.1", "@bitgo/utxo-lib": "^11.24.4", diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index e754b288522..d640290cd13 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -144,7 +144,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/sdk-opensslbytes": "^2.1.0", "@bitgo/sdk-test": "^9.1.86", "@openpgp/web-stream-tools": "0.0.14", diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createKeychains.ts index 3e0c0160462..1f253821f95 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createKeychains.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createKeychains.ts @@ -17,46 +17,9 @@ import { 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 PartyIndex = 0 | 1 | 2; +type SignedVrfMessage = { from: PartyIndex; message: string; signature: string }; +type EncryptedVrfMessage = { from: PartyIndex; to: PartyIndex; encryptedMessage: string; signature: string }; type VrfKeyshareDecoded = { threshold: number; @@ -69,10 +32,17 @@ type VrfKeyshareDecoded = { root_chain_code: Uint8Array; final_session_id: Uint8Array; }; -type VrfKeyGenResponseFields = { - bitgoVrfMsg1?: string; - bitgoVrfMsg2?: string; -}; + +/** Verifies a client VRF message the way BitGo does: signed by the sender's GPG key. */ +async function verifyClientVrfMessage( + message: SignedVrfMessage, + expectedFrom: PartyIndex, + gpgPublicKey: string +): Promise { + assert.equal(message.from, expectedFrom); + assert.ok(await DklsComms.verifySignedData(message, gpgPublicKey), `VRF message from ${expectedFrom} is not signed`); + return new Uint8Array(Buffer.from(message.message, 'base64')); +} describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { const coinName = 'hteth'; @@ -97,7 +67,11 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { let userGpgPubKey: { partyId: number; gpgKey: string }; let backupGpgPubKey: { partyId: number; gpgKey: string }; + // Per-test simulated BitGo misbehaviour, reset before each test. + let tamper: { round1Commitment?: boolean; round2OpeningToUser?: boolean; round2OpeningToBackup?: boolean } = {}; + beforeEach(async function () { + tamper = {}; nock.cleanAll(); await nockGetBitgoPublicKeyBasedOnFeatureFlags(coinName, enterpriseId, bitGoGgpKey); nock(bgUrl).get('/api/v1/client/constants').times(16).reply(200, { ttl: 3600, constants }); @@ -179,7 +153,7 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { assert.ok(backupKeychain.encryptedPrv); assert.ok(bitgoKeychain); - // The VRF DKG must have completed on the (simulated) HSM side as well. + // The VRF DKG must have completed on BitGo's (simulated) side as well. assert.ok(bitgoVrfShare, 'BitGo VRF keyshare was not produced'); assert.ok(userKeychain.encryptedPrv); @@ -228,6 +202,38 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { assert.equal(userKeychain.commonKeychain, bitgoKeychain.commonKeychain); }); + const keychainParams = () => ({ + passphrase: 'test', + enterprise: enterpriseId, + originalPasscodeEncryptionCode: '123456', + safeId, + }); + + async function runWithTamperedBitgo() { + const bitgoSession = new DklsDkg.Dkg(3, 2, 2); + const bitgoVrfSession = new DklsVrf.VrfDkg(3, 2, 2); + await nockKeyGenRound1(bitgoSession, bitgoVrfSession); + await nockKeyGenRound2(bitgoSession, bitgoVrfSession); + await nockKeyGenRound3(bitgoSession, bitgoVrfSession); + await nockAddKeyChain(coinName, 3); + return tssUtils.createKeychains(keychainParams()); + } + + it('should reject a BitGo VRF round 1 commitment whose signature does not verify', async function () { + tamper.round1Commitment = true; + await assert.rejects(runWithTamperedBitgo(), /Failed to authenticate BitGo VRF message 1/); + }); + + it('should reject a BitGo VRF opening to the user whose signature does not verify', async function () { + tamper.round2OpeningToUser = true; + await assert.rejects(runWithTamperedBitgo(), /Signed digest did not match/); + }); + + it('should reject a BitGo VRF opening to the backup whose signature does not verify', async function () { + tamper.round2OpeningToBackup = true; + await assert.rejects(runWithTamperedBitgo(), /Signed digest did not match/); + }); + async function nockGetBitgoPublicKeyBasedOnFeatureFlags( coin: string, enterpriseId: string, @@ -250,10 +256,7 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { .times(times) .reply( 200, - async ( - uri, - { payload }: { payload: MPCv2KeyGenRound1Request & VrfKeyGenRequestFields } - ): Promise => { + async (uri, { payload }: { payload: MPCv2KeyGenRound1Request }): 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'); @@ -305,20 +308,31 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { storedBitgoCommitment2 = bitgoToUserMsg2?.commitment; - // VRF DKG: BitGo commits, then consumes user and backup commitments. + // VRF DKG: verify the signed client commitments, then BitGo commits and consumes them. 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); + const userVrfCommitment = await verifyClientVrfMessage(userVrfMsg1, 0, userGpgPublicKey); + const backupVrfCommitment = await verifyClientVrfMessage(backupVrfMsg1, 1, backupGpgPublicKey); storedBitgoVrfOpenings = await bitgoVrfSession.handleIncomingMessages({ - broadcastMessages: [...userVrfCommitments.broadcastMessages, ...backupVrfCommitments.broadcastMessages], + broadcastMessages: [ + { from: 0, payload: userVrfCommitment }, + { from: 1, payload: backupVrfCommitment }, + ], p2pMessages: [], }); // BitGo's openings are addressed to each party, itself included. assert.equal(storedBitgoVrfOpenings.p2pMessages.length, 3); + assert.equal(bitgoVrfMsg1Unsigned.broadcastMessages.length, 1); + const bitgoVrfMsg1Signed: SignedVrfMessage = { + from: 2, + ...(await DklsComms.detachSignData( + Buffer.from(bitgoVrfMsg1Unsigned.broadcastMessages[0].payload), + bitgoGpgPrvKey.gpgKey + )), + }; + if (tamper.round1Commitment) { + // Valid signature, different payload: the signature no longer covers the message. + bitgoVrfMsg1Signed.message = Buffer.from('forged-commitment').toString('base64'); + } return { sessionId: 'testid' as NonEmptyString, @@ -336,7 +350,7 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { signature: bitgoToUserMsg2.payload.signature, }, walletGpgPubKeySigs: 'something' as NonEmptyString, - bitgoVrfMsg1: serializeVrfMessages(bitgoVrfMsg1Unsigned), + bitgoVrfMsg1: bitgoVrfMsg1Signed, }; } ); @@ -348,10 +362,7 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { .times(times) .reply( 200, - async ( - uri, - { payload }: { payload: MPCv2KeyGenRound2Request & VrfKeyGenRequestFields } - ): Promise => { + async (uri, { payload }: { payload: MPCv2KeyGenRound2Request }): Promise => { const { sessionId, userMsg2, backupMsg2, userCommitment2, backupCommitment2, userVrfMsg2, backupVrfMsg2 } = payload; assert.ok(userVrfMsg2, 'userVrfMsg2 must be present on a safe keygen round 2 payload'); @@ -393,11 +404,11 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { 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)); + // VRF DKG: the SDK sends only the opening addressed to BitGo, signed by the sending party. + const userOpening = await verifyClientVrfMessage(userVrfMsg2, 0, userGpgPubKey.gpgKey); + const backupOpening = await verifyClientVrfMessage(backupVrfMsg2, 1, backupGpgPubKey.gpgKey); + const userOpenings = [{ from: 0, to: 2, payload: userOpening }]; + const backupOpenings = [{ from: 1, to: 2, payload: backupOpening }]; assert.ok(storedBitgoVrfOpenings, 'BitGo VRF openings from round 1 not found'); await bitgoVrfSession.handleIncomingMessages({ broadcastMessages: [], @@ -426,13 +437,51 @@ describe('TSS Ecdsa VRF MPCv2 Utils:', async function () { 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), + // BitGo's openings, each PGP-encrypted to its recipient and signed over the plaintext. + bitgoVrfMsgToUser: await encryptVrfOpening( + storedBitgoVrfOpenings, + 0, + userGpgPubKey.gpgKey, + tamper.round2OpeningToUser + ), + bitgoVrfMsgToBackup: await encryptVrfOpening( + storedBitgoVrfOpenings, + 1, + backupGpgPubKey.gpgKey, + tamper.round2OpeningToBackup + ), }; } ); } + /** Encrypts BitGo's VRF opening for `to` with that party's GPG key and signs the plaintext. */ + async function encryptVrfOpening( + openings: DklsTypes.DeserializedMessages, + to: PartyIndex, + partyGpgPublicKey: string, + tamperPlaintext = false + ): Promise { + const opening = openings.p2pMessages.find((m) => m.to === to); + assert.ok(opening, `BitGo VRF opening for party ${to} not found`); + const signedFor = Buffer.from(opening.payload); + const { encryptedMessage, signature } = await DklsComms.encryptAndDetachSignData( + signedFor, + partyGpgPublicKey, + bitgoGpgPrvKey.gpgKey + ); + if (!tamperPlaintext) { + return { from: 2, to, encryptedMessage, signature }; + } + // Encrypt a different plaintext but keep the signature over the original one. + const forged = await DklsComms.encryptAndDetachSignData( + Buffer.from('forged-opening'), + partyGpgPublicKey, + bitgoGpgPrvKey.gpgKey + ); + return { from: 2, to, encryptedMessage: forged.encryptedMessage, signature }; + } + async function nockKeyGenRound3(bitgoSession: DklsDkg.Dkg, bitgoVrfSession: DklsVrf.VrfDkg, times = 1) { return nock(bgUrl) .post(`/api/v2/mpc/generatekey`, (body) => body.round === 'MPCv2-R3') diff --git a/modules/express/package.json b/modules/express/package.json index 12e2b971b37..41d1e9ae3fe 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -41,7 +41,7 @@ "@api-ts/typed-express-router": "2.0.0", "@bitgo/abstract-lightning": "^8.5.1", "@bitgo/logger": "^1.4.0", - "@bitgo/public-types": "6.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/sdk-core": "^39.1.0", "@bitgo/utxo-lib": "^11.24.4", "@types/proxyquire": "^1.3.31", diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index 88476b986be..6a0273f8098 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -35,7 +35,7 @@ "access": "public" }, "dependencies": { - "@bitgo/public-types": "6.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/sdk-core": "^39.1.0" }, "devDependencies": { diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index edd0584c883..28a3c40ed53 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.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/sdk-core": "^39.1.0", "@bitgo/secp256k1": "^1.11.1", "@bitgo/statics": "^59.23.1", diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index f0b7dae4b34..5a584e57597 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.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/sdk-core": "^39.1.0", "@bitgo/sdk-lib-mpc": "^11.5.0", "@bitgo/statics": "^59.23.1", diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 88ba3dc5bf3..20284835b50 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/sdk-lib-mpc": "^11.5.0", "@bitgo/sdk-lib-safes": "^1.6.0", "@bitgo/secp256k1": "^1.11.1", diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts index 5097ccc581e..91df2d82fca 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts @@ -25,43 +25,36 @@ import { KeyGenSenderForEnterprise, KeyGenSenderForSafeChild, } from './ecdsaMPCv2KeyGenSender'; -import { MPCv2PartiesEnum, MpcV2VrfKeyGenResponseFields } from './typesMPCv2'; +import { MPCv2PartiesEnum } from './typesMPCv2'; -/** - * 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 +/** A VRF round emits exactly one message per recipient; this is the one a party signs for BitGo. */ +function getSingleVrfPayload(messages: { payload: Uint8Array }[], description: string): Uint8Array { + assert.equal(messages.length, 1, `${description} must produce exactly one message`); + return messages[0].payload; } -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'); +/** Signs a VRF payload with the sending party's GPG key, in the signed-broadcast shape the HSM verifies. */ +async function signVrfMessage(from: MPCv2PartiesEnum, payload: Uint8Array, gpgKey: string) { + return { from, ...(await DklsComms.detachSignData(Buffer.from(payload), gpgKey)) }; } -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')) })), - }; +/** Verifies BitGo's signature on its VRF round 1 broadcast and returns the raw payload. */ +async function verifyBitgoVrfBroadcast( + message: { message: string; signature: string }, + bitgoGpgKey: string +): Promise { + assert(await DklsComms.verifySignedData(message, bitgoGpgKey), 'Failed to authenticate BitGo VRF message 1'); + return new Uint8Array(Buffer.from(message.message, 'base64')); +} + +/** Decrypts a BitGo VRF opening encrypted to a client party's GPG key and verifies BitGo's signature over it. */ +async function decryptBitgoVrfOpening( + message: { encryptedMessage: string; signature: string }, + partyGpgKey: string, + bitgoGpgKey: string +): Promise { + const base64Payload = await DklsComms.decryptAndVerifySignedData(message, bitgoGpgKey, partyGpgKey); + return new Uint8Array(Buffer.from(base64Payload, 'base64')); } /** @@ -142,8 +135,18 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { const backupGpgPublicKey = backupGpgKey.publicKey; assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); - // Keep VRF messages opaque on the key-generation request. - const round1Sender = KeyGenSenderForEnterprise( + // VRF messages ride the key-generation request as signed messages, like userMsg1. + const userSignedVrfMsg1 = await signVrfMessage( + MPCv2PartiesEnum.USER, + getSingleVrfPayload(userVrfRound1Msg.broadcastMessages, 'User VRF round 1'), + userGpgPrvKey.gpgKey + ); + const backupSignedVrfMsg1 = await signVrfMessage( + MPCv2PartiesEnum.BACKUP, + getSingleVrfPayload(backupVrfRound1Msg.broadcastMessages, 'Backup VRF round 1'), + backupGpgPrvKey.gpgKey + ); + const round1Sender = KeyGenSenderForEnterprise( this.bitgo, params.enterprise, params.safeId @@ -155,8 +158,8 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { backupMsg1: { from: MPCv2PartiesEnum.BACKUP, ...backupMsg1 }, userGpgPublicKey, backupGpgPublicKey, - userVrfMsg1: serializeVrfMessages(userVrfRound1Msg), - backupVrfMsg1: serializeVrfMessages(backupVrfRound1Msg), + userVrfMsg1: userSignedVrfMsg1, + backupVrfMsg1: backupSignedVrfMsg1, walletId: params.retrofit?.walletId, } ); @@ -194,23 +197,37 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { ); assert(serializedBackupToBitgoMsg2, 'Backup message 2 not found in P2P messages'); - // VRF: consume BitGo's and the counterparty's commitments, emit this party's openings. + // VRF: consume BitGo's signed commitment and the counterparty's, emit this party's openings. // Openings come back as p2p messages addressed to each party (including the sender). + const bitgoVrfBroadcast1 = { + from: MPCv2PartiesEnum.BITGO, + payload: await verifyBitgoVrfBroadcast(bitgoVrfMsg1, bitgoPublicGpgKey), + }; const userVrfRound2Messages = await userVrfSession.handleIncomingMessages({ - broadcastMessages: [ - ...backupVrfRound1Msg.broadcastMessages, - ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.USER).broadcastMessages, - ], + broadcastMessages: [...backupVrfRound1Msg.broadcastMessages, bitgoVrfBroadcast1], p2pMessages: [], }); - const backupVrfRound2Messages = await backupVrfSession.handleIncomingMessages({ - broadcastMessages: [ - ...userVrfRound1Msg.broadcastMessages, - ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.BACKUP).broadcastMessages, - ], + broadcastMessages: [...userVrfRound1Msg.broadcastMessages, bitgoVrfBroadcast1], p2pMessages: [], }); + // Only the opening addressed to BitGo goes to the HSM, signed by the sending party. + const userSignedVrfMsg2 = await signVrfMessage( + MPCv2PartiesEnum.USER, + getSingleVrfPayload( + userVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.BITGO), + 'User VRF round 2 opening for BitGo' + ), + userGpgPrvKey.gpgKey + ); + const backupSignedVrfMsg2 = await signVrfMessage( + MPCv2PartiesEnum.BACKUP, + getSingleVrfPayload( + backupVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.BITGO), + 'Backup VRF round 2 opening for BitGo' + ), + backupGpgPrvKey.gpgKey + ); const round2Messages = await DklsComms.encryptAndAuthOutgoingMessages( { p2pMessages: [serializedUserToBitgoMsg2, serializedBackupToBitgoMsg2], broadcastMessages: [] }, @@ -229,16 +246,14 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { 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 round2Sender = KeyGenSenderForEnterprise(this.bitgo, params.enterprise); const { sessionId: sessionIdRound2, bitgoCommitment2, bitgoToUserMsg3, bitgoToBackupMsg3, - bitgoVrfMsg2, + bitgoVrfMsgToUser, + bitgoVrfMsgToBackup, } = await round2Sender(MPCv2KeyGenStateEnum['MPCv2-R2'], { sessionId, userMsg2: { @@ -255,24 +270,35 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { encryptedMessage: backupRound2Msg.payload.encryptedMessage, }, backupCommitment2: backupRound2Msg.commitment, - userVrfMsg2: serializeVrfMessages(userVrfRound2Messages), - backupVrfMsg2: serializeVrfMessages(backupVrfRound2Messages), + userVrfMsg2: userSignedVrfMsg2, + backupVrfMsg2: backupSignedVrfMsg2, }); // #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. + assert(bitgoVrfMsgToUser, 'BitGo VRF message to user not found in round 2 response'); + assert(bitgoVrfMsgToBackup, 'BitGo VRF message to backup not found in round 2 response'); + + // VRF: finalize both DKGs locally. Each client party consumes its own opening, the counterparty's, + // and BitGo's opening, which BitGo encrypted to that party's GPG key and signed. The VRF DKG + // completes here, a full round of slack before MPCv2-R3. + const bitgoVrfOpeningToUser = await decryptBitgoVrfOpening( + bitgoVrfMsgToUser, + userGpgPrvKey.gpgKey, + bitgoPublicGpgKey + ); + const bitgoVrfOpeningToBackup = await decryptBitgoVrfOpening( + bitgoVrfMsgToBackup, + backupGpgPrvKey.gpgKey, + bitgoPublicGpgKey + ); 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, + { from: MPCv2PartiesEnum.BITGO, to: MPCv2PartiesEnum.USER, payload: bitgoVrfOpeningToUser }, ], }); await backupVrfSession.handleIncomingMessages({ @@ -280,7 +306,7 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { p2pMessages: [ ...userVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.BACKUP), ...backupVrfRound2Messages.p2pMessages.filter((m) => m.to === MPCv2PartiesEnum.BACKUP), - ...deserializeVrfMessages(bitgoVrfMsg2, MPCv2PartiesEnum.BACKUP).p2pMessages, + { from: MPCv2PartiesEnum.BITGO, to: MPCv2PartiesEnum.BACKUP, payload: bitgoVrfOpeningToBackup }, ], }); 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 db7d9f01912..ec90754a78c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts @@ -32,27 +32,9 @@ export const generateMPCv2KeyRequestResponse = t.union([ MPCv2KeyGenRound3Response, ]); -/** - * 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 GenerateMPCv2KeyRequestBody = t.TypeOf; -export type MpcV2VrfKeyGenResponseFields = { - bitgoVrfMsg1?: string; - bitgoVrfMsg2?: string; -}; - -export type GenerateMPCv2KeyRequestBody = t.TypeOf & MpcV2VrfKeyGenRequestFields; - -export type GenerateMPCv2KeyRequestResponse = t.TypeOf & - MpcV2VrfKeyGenResponseFields; +export type GenerateMPCv2KeyRequestResponse = t.TypeOf; export type GenerateMPCv2DeriveKeyRequest = | MPCv2DeriveRound1Request diff --git a/modules/sdk-lib-safes/package.json b/modules/sdk-lib-safes/package.json index 6e87002dd94..2fc56e910b0 100644 --- a/modules/sdk-lib-safes/package.json +++ b/modules/sdk-lib-safes/package.json @@ -31,7 +31,7 @@ "access": "public" }, "dependencies": { - "@bitgo/public-types": "6.83.0", + "@bitgo/public-types": "6.88.0", "@bitgo/statics": "^59.23.1", "@bitgo/utxo-lib": "^11.24.4", "create-hmac": "^1.1.7", diff --git a/yarn.lock b/yarn.lock index e1b1d8645ec..fbd3f52e6e7 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.83.0": - version "6.83.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.83.0.tgz#1d89f56e041587cc81cad031c535d299c0de5595" - integrity sha512-L8Orddqr4ODQnKKkcim4hWqDlrhH2mqSDgh/faXh7h3cc5JRZbL92YRvS9ASJNVOWSs6Wk8DFVyMmbyss+ggbQ== +"@bitgo/public-types@6.88.0": + version "6.88.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.88.0.tgz#eae8213102534218ad96ba27bbf7a02d48f8b7d6" + integrity sha512-4C7MWjbjfNbN2iw4JcuvfxBg08xWlpG3C2UTLjEh8ZmGcfxSvy99ERhsZu+qhh7u89AgWkFs0Q8KN8CuRXi56w== dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4"