diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index 5e6c64ba113..051b40f7d1c 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.90.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..183953f9a0d 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.90.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/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts index 5b16ebadc65..74a1eb905b0 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts @@ -383,8 +383,9 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct body.round === 'MPCv2Derive-R1' && body.safeId === SAFE_ID && body.payload?.parentKeyId === BITGO_ROOT_KEY_ID && - // TODO(WCN-2969): R1 carries the path once public-types adds it; today the account rides as derivationIndex. - body.payload?.derivationIndex === account && + body.coin === coinName && + body.payload?.path === childPath(account) && + body.payload?.derivationIndex === undefined && body.payload?.userGpgPublicKey && body.payload?.userMsg1 && body.payload?.backupGpgPublicKey === undefined && diff --git a/modules/express/package.json b/modules/express/package.json index 12e2b971b37..37efcd97e1a 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.90.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..7e657976d18 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.90.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..e3b94606367 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.90.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..fa728fd464e 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.90.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..f769efb99fd 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.90.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/ecdsaMPCv2KeyGenSender.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts index db9b2d1023d..18d71dfeb29 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts @@ -36,12 +36,14 @@ export type EcdsaMPCv2DeriveKeySendFn( bitgo: BitGoBase, enterprise: string, - safeId: string + safeId: string, + coin: string ): EcdsaMPCv2DeriveKeySendFn { return (round, payload) => { return bitgo @@ -49,6 +51,7 @@ export function KeyGenSenderForSafeChild ({ 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'); +/** 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')); } -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')) })), - }; +/** 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 }, ], }); @@ -509,29 +535,27 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { } } /** - * Sends round 1 for the user/BitGo hard-derive ceremony. + * Sends round 1 for the user/BitGo hard-derive ceremony. `path` is the full Safe child path + * `m/44'/'/'/'`; the service compares it with the path it expects. */ async sendDerivationRound1BySender( senderFn: EcdsaMPCv2DeriveKeySendFn, userGpgPublicKey: string, payload: DklsTypes.AuthEncMessages, parentKeyId: string, - derivationIndex: number + path: string ): Promise { assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); assert(NonEmptyString.is(parentKeyId), 'Parent key id is required'); const userMsg1 = payload.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; assert(userMsg1, 'User message 1 not found in broadcast messages'); - assert( - MPCv2DeriveRound1Request.props.derivationIndex.is(derivationIndex), - 'Derivation index must be a non-negative safe integer' - ); + assert(MPCv2DeriveRound1Request.props.path.is(path), 'Derivation path must be a hardened BIP44 Safe child path'); const request: MPCv2DeriveRound1Request = { userGpgPublicKey, userMsg1: { from: MPCv2PartiesEnum.USER, ...userMsg1 }, parentKeyId, - derivationIndex, + path, }; return senderFn(MPCv2KeyGenStateEnum['MPCv2Derive-R1'], request); } @@ -631,12 +655,16 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { ); assert(NonEmptyString.is(userGpgKey.publicKey), 'User GPG public key is required'); const { sessionId, bitgoMsg1 } = await this.sendDerivationRound1BySender( - KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + KeyGenSenderForSafeChild( + this.bitgo, + params.enterprise, + params.safeId, + this.baseCoin.getChain() + ), userGpgKey.publicKey, round1Messages, params.parentKeyId, - // TODO(WCN-2969): send `path` once the public-types derive round 1 request carries it. - account + params.path ); // Round 2: process the peer's first message and return the user's second. @@ -657,7 +685,12 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { [userGpgPrvKey] ); const { sessionId: sessionIdRound2, bitgoMsg2 } = await this.sendDerivationRound2BySender( - KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + KeyGenSenderForSafeChild( + this.bitgo, + params.enterprise, + params.safeId, + this.baseCoin.getChain() + ), sessionId, round2Messages ); @@ -678,7 +711,12 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { // Round 3: obtain the child common-keychain metadata. const { sessionId: sessionIdRound3, commonKeychain } = await this.sendDerivationRound3BySender( - KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + KeyGenSenderForSafeChild( + this.bitgo, + params.enterprise, + params.safeId, + this.baseCoin.getChain() + ), sessionId ); assert.equal(sessionId, sessionIdRound3, 'Round 1 and 3 Session IDs do not match'); 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-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts index 5ae8992e2e9..1b49a134869 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts @@ -32,16 +32,20 @@ export type EddsaMPCv2DeriveKeySendFn Promise; -/** The EdDSA safe-child derive uses the keygen endpoint and a distinct two-round state family. */ +/** + * The EdDSA safe-child derive uses the keygen endpoint and a distinct two-round state family. `coin` + * selects the BIP44 coin type the service expects in the round 1 `path`. + */ export function KeyGenSenderForSafeChild( bitgo: BitGoBase, enterprise: string, - safeId: string + safeId: string, + coin: string ): EddsaMPCv2DeriveKeySendFn { return (round, payload) => { return bitgo .post(bitgo.url('/mpc/generatekey', 2)) - .send({ enterprise, safeId, type: KeyGenTypeEnum.MPCv2, curveType: KeyCurveEnum.EdDSA, round, payload }) + .send({ enterprise, safeId, coin, type: KeyGenTypeEnum.MPCv2, curveType: KeyCurveEnum.EdDSA, round, payload }) .result(); }; } diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts index 3525ffe4591..bc41188c7cb 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts @@ -295,14 +295,13 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { userGpgPublicKey: string, userMsg1: MPSTypes.MPSSignedMessage, parentKeyId: string, - derivationIndex: number + path: string ): Promise { const request = decodeWithCodec( EddsaMPCv2DeriveRound1Request, - { userGpgPublicKey, userMsg1, parentKeyId, derivationIndex }, + { userGpgPublicKey, userMsg1, parentKeyId, path }, 'EddsaMPCv2DeriveRound1Request' ); - assert(request.derivationIndex <= 0x7fffffff, 'Derivation index must fit a hardened child path'); return decodeWithCodec( EddsaMPCv2DeriveRound1Response, await senderFn(MPCv2KeyGenStateEnum['MPCv2Derive-R1'], request), @@ -348,7 +347,8 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { assert(NonEmptyString.is(params.backupRootKeyId), 'Backup root key ID is required for child derivation'); assert(NonEmptyString.is(params.parentKeyId), 'BitGo root key ID is required for child derivation'); const { path } = params; - const { account } = parseSafeBip44Path(path); + // Reject a malformed path before any network call; the service re-validates it against its expected path. + parseSafeBip44Path(path); const userKeyPair = await generateGPGKeyPair('ed25519'); const userGpgKey = await pgp.readPrivateKey({ armoredKey: userKeyPair.privateKey }); @@ -373,12 +373,16 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { const userMsg1 = await userDeriveSession.initDerive(); const signedUserMsg1 = await MPSComms.detachSignMpsMessage(userMsg1.payload, userGpgKey); const { sessionId, bitgoMsg1 } = await this.sendDerivationRound1BySender( - KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + KeyGenSenderForSafeChild( + this.bitgo, + params.enterprise, + params.safeId, + this.baseCoin.getChain() + ), userKeyPair.publicKey, signedUserMsg1, params.parentKeyId, - // TODO(WCN-2969): send `path` once the public-types derive round 1 request carries it. - account + path ); const [userMsg2] = userDeriveSession.handleIncomingMessages([ @@ -391,7 +395,12 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { commonPublicKeychain, bitgoMsg2, } = await this.sendDerivationRound2BySender( - KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + KeyGenSenderForSafeChild( + this.bitgo, + params.enterprise, + params.safeId, + this.baseCoin.getChain() + ), sessionId, signedUserMsg2 ); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts index 87e4eb37e9f..c805792273d 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts @@ -77,6 +77,7 @@ function createKeychainFixture() { }; const baseCoin = { keychains: () => keychains, + getChain: () => 'tsol', } as unknown as IBaseCoin; return { bitgo, baseCoin, post, encryptedInputs, addedKeychains }; } @@ -458,7 +459,7 @@ describe('EdDSA safe-child MPS derivation', function () { const path = buildSafeBip44Path(501, 'eddsaMpc', account); const Request = t.intersection([ t.type({ enterprise: t.string, type: t.string, curveType: t.string, round: t.string, payload: t.unknown }), - t.partial({ safeId: t.string }), + t.partial({ safeId: t.string, coin: t.string }), ]); before(async function () { @@ -494,6 +495,7 @@ describe('EdDSA safe-child MPS derivation', function () { const body = decodeWithCodec(Request, rawBody, 'EdDSA derive request'); assert.strictEqual(_url, '/mpc/generatekey'); assert.strictEqual(body.enterprise, 'enterprise-id'); + assert.strictEqual(body.coin, 'tsol'); assert.strictEqual(body.type, KeyGenTypeEnum.MPCv2); assert.strictEqual(body.curveType, KeyCurveEnum.EdDSA); if (rounds++ === 0) { @@ -501,13 +503,13 @@ describe('EdDSA safe-child MPS derivation', function () { assert.strictEqual(body.safeId, 'safe-id'); const request = decodeWithCodec(EddsaMPCv2DeriveRound1Request, body.payload, 'EdDSA derive round 1'); assert.deepStrictEqual(Object.keys(request).sort(), [ - 'derivationIndex', 'parentKeyId', + 'path', 'userGpgPublicKey', 'userMsg1', ]); assert.strictEqual(request.parentKeyId, 'bitgo-root-id'); - assert.strictEqual(request.derivationIndex, account); + assert.strictEqual(request.path, path); userGpgPublicKey = request.userGpgPublicKey; const userGpgKey = await pgp.readKey({ armoredKey: request.userGpgPublicKey }); const userPayload = await MPSComms.verifyMpsMessage(request.userMsg1, userGpgKey); diff --git a/modules/sdk-lib-safes/package.json b/modules/sdk-lib-safes/package.json index 6e87002dd94..de77d59572a 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.90.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..54d8c89d19d 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.90.0": + version "6.90.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.90.0.tgz#01cbded37315be24451c0e357df57f99dc7668ca" + integrity sha512-8JmcPhiovAjfm75/z5/BCtFJC/jjyCL6D52yE+LtQL2T2A58XccNrzEQZey5Ndo5gs1ARyFqxatL3CD8CZkoGw== dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4"