From 9b0adf554b43b8eb52ae4629403bcd2460ce7dd2 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 9 Oct 2026 11:45:49 -0400 Subject: [PATCH] feat(sdk-core): sign EdDSA safe MPS VRF keygen messages like the HSM Ticket: WCN-2991 --- modules/abstract-lightning/package.json | 2 +- modules/bitgo/package.json | 2 +- modules/express/package.json | 2 +- modules/passkey-crypto/package.json | 2 +- modules/sdk-coin-flrp/package.json | 2 +- modules/sdk-coin-sol/package.json | 2 +- modules/sdk-core/package.json | 2 +- .../bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts | 147 +++++----- .../bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts | 22 +- .../bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts | 253 +++++++++++++++--- modules/sdk-lib-safes/package.json | 2 +- yarn.lock | 19 +- 12 files changed, 306 insertions(+), 151 deletions(-) diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index 5658c7dada..a226d8a7ad 100644 --- a/modules/abstract-lightning/package.json +++ b/modules/abstract-lightning/package.json @@ -39,7 +39,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.79.0", + "@bitgo/public-types": "6.83.0", "@bitgo/sdk-core": "^38.23.0", "@bitgo/statics": "^59.22.0", "@bitgo/utxo-lib": "^11.24.4", diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index d2def40967..499e1f7c9a 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -144,7 +144,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.79.0", + "@bitgo/public-types": "6.83.0", "@bitgo/sdk-opensslbytes": "^2.1.0", "@bitgo/sdk-test": "^9.1.84", "@openpgp/web-stream-tools": "0.0.14", diff --git a/modules/express/package.json b/modules/express/package.json index a5fe88654e..3d59d25721 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.4.3", "@bitgo/logger": "^1.4.0", - "@bitgo/public-types": "6.79.0", + "@bitgo/public-types": "6.83.0", "@bitgo/sdk-core": "^38.23.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 42b2daf2c0..8a9855c06e 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -35,7 +35,7 @@ "access": "public" }, "dependencies": { - "@bitgo/public-types": "6.79.0", + "@bitgo/public-types": "6.83.0", "@bitgo/sdk-core": "^38.23.0" }, "devDependencies": { diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index 80b39020f1..cc0a9f9b29 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.79.0", + "@bitgo/public-types": "6.83.0", "@bitgo/sdk-core": "^38.23.0", "@bitgo/secp256k1": "^1.11.1", "@bitgo/statics": "^59.22.0", diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index 248bbba5e3..c83655e569 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.79.0", + "@bitgo/public-types": "6.83.0", "@bitgo/sdk-core": "^38.23.0", "@bitgo/sdk-lib-mpc": "^11.3.0", "@bitgo/statics": "^59.22.0", diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 2ca6c44246..be3aefef13 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.80.0", + "@bitgo/public-types": "6.83.0", "@bitgo/sdk-lib-mpc": "^11.3.0", "@bitgo/sdk-lib-safes": "^1.4.0", "@bitgo/secp256k1": "^1.11.1", 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 c8a20a79b1..1aee562aab 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts @@ -1,4 +1,4 @@ -import { DklsTypes, MPSComms, MpsDerive, MpsVrf, type MPSTypes } from '@bitgo/sdk-lib-mpc'; +import { MPSComms, MpsDerive, MpsVrf, type MPSTypes } from '@bitgo/sdk-lib-mpc'; import { EddsaMPCv2DeriveRound1Request, EddsaMPCv2DeriveRound1Response, @@ -7,10 +7,10 @@ import { MPCv2KeyGenStateEnum, type EddsaMPCv2KeyGenRound1Response, type EddsaMPCv2KeyGenRound2Response, + type EddsaMPCv2SignedMessage, } from '@bitgo/public-types'; import { DerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; import assert from 'assert'; -import * as t from 'io-ts'; import * as pgp from 'openpgp'; import { NonEmptyString } from 'io-ts-types'; @@ -20,69 +20,39 @@ import type { EncryptionVersion } from '../../../../api'; import { generateGPGKeyPair } from '../../opengpgUtils'; import type { WebauthnKeyEncryptionInfo } from '../../../keychain'; import { envRequiresBitgoPubGpgKeyConfig, isBitgoEddsaMpcv2PubKey } from '../../../tss/bitgoPubKeys'; -import { base64String, boundedInt, decodeWithCodec } from '../../codecs'; +import { decodeWithCodec } from '../../codecs'; import { EddsaMPCv2Utils } from './eddsaMPCv2'; import { EddsaMPCv2DeriveKeySendFn, KeyGenSenderForEnterprise, KeyGenSenderForSafeChild, } from './eddsaMPCv2KeyGenSender'; -import type { EddsaMPCv2VrfKeyGenResponseFields } from './typesEddsaMPCv2'; import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2'; import { buildSafeMpcKeyEnvelopes } from '../keyShareEnvelope'; -const VrfPartyId = boundedInt(0, 2, 'VrfPartyId'); -const VrfMessageTransferCodec = t.intersection([ - t.type({ - from: VrfPartyId, - payload: base64String, - }), - t.partial({ to: VrfPartyId }), -]); -const VrfMessageTransfersCodec = t.array(VrfMessageTransferCodec); - -type VrfMessageTransfer = t.TypeOf; - -export function serializeVrfMessages(messages: DklsTypes.DeserializedMessages): string { - const transfers: VrfMessageTransfer[] = [ - ...messages.broadcastMessages.map((message) => ({ - from: message.from, - payload: Buffer.from(message.payload).toString('base64'), - })), - ...messages.p2pMessages.map((message) => ({ - from: message.from, - to: message.to, - payload: Buffer.from(message.payload).toString('base64'), - })), - ]; - return Buffer.from(JSON.stringify(transfers)).toString('base64'); +/** Returns the single payload a VRF round produced, which is what the 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; } -export function deserializeVrfMessages(blob: string, forParty: number): DklsTypes.DeserializedMessages { - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(blob, 'base64').toString()); - } catch (error) { - const reason = error instanceof Error ? error.message : 'malformed JSON'; - throw new Error(`Invalid VRF DKG message blob: ${reason}`); - } - - const transfers = decodeWithCodec(VrfMessageTransfersCodec, parsed, 'VRF DKG message blob'); - return { - broadcastMessages: transfers - .filter((message) => message.to === undefined) - .map((message) => ({ - from: message.from, - payload: new Uint8Array(Buffer.from(message.payload, 'base64')), - })), - p2pMessages: transfers - .filter((message): message is VrfMessageTransfer & { to: number } => message.to === forParty) - .map((message) => ({ - from: message.from, - to: message.to, - payload: new Uint8Array(Buffer.from(message.payload, 'base64')), - })), - }; +/** + * Decrypts a BitGo VRF opening encrypted to a client party's GPG key and verifies BitGo's + * detached signature over the plaintext. + */ +async function decryptBitgoVrfOpening( + message: EddsaMPCv2SignedMessage, + partyKey: pgp.PrivateKey, + bitgoKey: pgp.Key +): Promise { + const { data } = await pgp.decrypt({ + message: await pgp.readMessage({ armoredMessage: message.message }), + decryptionKeys: partyKey, + format: 'binary', + }); + const plaintext = Buffer.from(data); + await MPSComms.verifyMpsMessage({ message: plaintext.toString('base64'), signature: message.signature }, bitgoKey); + return new Uint8Array(plaintext); } /** @@ -137,11 +107,19 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { const userSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgKey); const backupSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg1.payload), backupGpgKey); + const userSignedVrfMsg1 = await MPSComms.detachSignMpsMessage( + getSingleVrfPayload(userVrfMsg1.broadcastMessages, 'User VRF round 0'), + userGpgKey + ); + const backupSignedVrfMsg1 = await MPSComms.detachSignMpsMessage( + getSingleVrfPayload(backupVrfMsg1.broadcastMessages, 'Backup VRF round 0'), + backupGpgKey + ); assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); - const round1Sender = KeyGenSenderForEnterprise( + const round1Sender = KeyGenSenderForEnterprise( this.bitgo, params.enterprise, params.safeId @@ -151,8 +129,8 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { backupGpgPublicKey, userMsg1: userSignedMsg1, backupMsg1: backupSignedMsg1, - userVrfMsg1: serializeVrfMessages(userVrfMsg1), - backupVrfMsg1: serializeVrfMessages(backupVrfMsg1), + userVrfMsg1: userSignedVrfMsg1, + backupVrfMsg1: backupSignedVrfMsg1, ...(params.retrofit?.walletId ? { walletId: params.retrofit.walletId } : {}), }); assert(bitgoVrfMsg1, 'BitGo VRF message 1 not found in round 1 response'); @@ -176,55 +154,68 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { const userSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgKey); const backupSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg2.payload), backupGpgKey); + const bitgoVrfRawMsg1Bytes = await MPSComms.verifyMpsMessage(bitgoVrfMsg1, bitgoKeyObj); + const bitgoVrfDeserializedMsg1: MPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.BITGO, + payload: new Uint8Array(bitgoVrfRawMsg1Bytes), + }; const userVrfMsg2 = await userVrfSession.handleIncomingMessages({ - broadcastMessages: [ - ...backupVrfMsg1.broadcastMessages, - ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.USER).broadcastMessages, - ], + broadcastMessages: [...backupVrfMsg1.broadcastMessages, bitgoVrfDeserializedMsg1], p2pMessages: [], }); const backupVrfMsg2 = await backupVrfSession.handleIncomingMessages({ - broadcastMessages: [ - ...userVrfMsg1.broadcastMessages, - ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.BACKUP).broadcastMessages, - ], + broadcastMessages: [...userVrfMsg1.broadcastMessages, bitgoVrfDeserializedMsg1], p2pMessages: [], }); - - const round2Sender = KeyGenSenderForEnterprise( - this.bitgo, - params.enterprise + const userSignedVrfMsg2 = await MPSComms.detachSignMpsMessage( + getSingleVrfPayload( + userVrfMsg2.p2pMessages.filter((message) => message.to === MPCv2PartiesEnum.BITGO), + 'User VRF round 1 opening for BitGo' + ), + userGpgKey + ); + const backupSignedVrfMsg2 = await MPSComms.detachSignMpsMessage( + getSingleVrfPayload( + backupVrfMsg2.p2pMessages.filter((message) => message.to === MPCv2PartiesEnum.BITGO), + 'Backup VRF round 1 opening for BitGo' + ), + backupGpgKey ); + + const round2Sender = KeyGenSenderForEnterprise(this.bitgo, params.enterprise); const { sessionId: sessionIdRound2, commonPublicKeychain, bitgoMsg2, - bitgoVrfMsg2, + bitgoVrfMsgToUser, + bitgoVrfMsgToBackup, } = await round2Sender(MPCv2KeyGenStateEnum['MPCv2-R2'], { sessionId, userMsg2: userSignedMsg2, backupMsg2: backupSignedMsg2, - userVrfMsg2: serializeVrfMessages(userVrfMsg2), - backupVrfMsg2: serializeVrfMessages(backupVrfMsg2), + userVrfMsg2: userSignedVrfMsg2, + backupVrfMsg2: backupSignedVrfMsg2, }); assert.equal(sessionId, sessionIdRound2, 'Round 1 and round 2 session IDs do not match'); - assert(bitgoVrfMsg2, 'BitGo VRF message 2 not found in round 2 response'); + 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 finalizes locally after the second existing MPCv2 round. + // VRF finalizes locally after the second existing MPCv2 round. BitGo's openings are encrypted + // to the recipient party's GPG key and signed by BitGo's key. + const bitgoVrfOpeningToUser = await decryptBitgoVrfOpening(bitgoVrfMsgToUser, userGpgKey, bitgoKeyObj); + const bitgoVrfOpeningToBackup = await decryptBitgoVrfOpening(bitgoVrfMsgToBackup, backupGpgKey, bitgoKeyObj); await userVrfSession.handleIncomingMessages({ broadcastMessages: [], p2pMessages: [ - ...userVrfMsg2.p2pMessages.filter((message) => message.to === MPCv2PartiesEnum.USER), ...backupVrfMsg2.p2pMessages.filter((message) => message.to === MPCv2PartiesEnum.USER), - ...deserializeVrfMessages(bitgoVrfMsg2, MPCv2PartiesEnum.USER).p2pMessages, + { from: MPCv2PartiesEnum.BITGO, to: MPCv2PartiesEnum.USER, payload: bitgoVrfOpeningToUser }, ], }); await backupVrfSession.handleIncomingMessages({ broadcastMessages: [], p2pMessages: [ ...userVrfMsg2.p2pMessages.filter((message) => message.to === MPCv2PartiesEnum.BACKUP), - ...backupVrfMsg2.p2pMessages.filter((message) => message.to === MPCv2PartiesEnum.BACKUP), - ...deserializeVrfMessages(bitgoVrfMsg2, MPCv2PartiesEnum.BACKUP).p2pMessages, + { from: MPCv2PartiesEnum.BITGO, to: MPCv2PartiesEnum.BACKUP, payload: bitgoVrfOpeningToBackup }, ], }); // #endregion diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts index 43bb1da913..f456cb51c0 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts @@ -12,32 +12,14 @@ import { export const generateEddsaMPCv2KeyRequestBody = t.union([EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound2Request]); -/** Opaque VRF DKG blobs carried by the existing MPS key-generation rounds. */ -export const eddsaMPCv2VrfKeyGenRequestFields = t.partial({ - userVrfMsg1: t.string, - backupVrfMsg1: t.string, - userVrfMsg2: t.string, - backupVrfMsg2: t.string, -}); - -export const eddsaMPCv2VrfKeyGenResponseFields = t.partial({ - bitgoVrfMsg1: t.string, - bitgoVrfMsg2: t.string, -}); - -export type EddsaMPCv2VrfKeyGenRequestFields = t.TypeOf; -export type EddsaMPCv2VrfKeyGenResponseFields = t.TypeOf; - -export type GenerateEddsaMPCv2KeyRequestBody = t.TypeOf & - EddsaMPCv2VrfKeyGenRequestFields; +export type GenerateEddsaMPCv2KeyRequestBody = t.TypeOf; export const generateEddsaMPCv2KeyRequestResponse = t.union([ EddsaMPCv2KeyGenRound1Response, EddsaMPCv2KeyGenRound2Response, ]); -export type GenerateEddsaMPCv2KeyRequestResponse = t.TypeOf & - EddsaMPCv2VrfKeyGenResponseFields; +export type GenerateEddsaMPCv2KeyRequestResponse = t.TypeOf; export type GenerateEddsaMPCv2DeriveKeyRequest = EddsaMPCv2DeriveRound1Request | EddsaMPCv2DeriveRound2Request; export type GenerateEddsaMPCv2DeriveKeyRequestResponse = 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 25b47241f2..30740004b4 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 @@ -1,10 +1,11 @@ import assert from 'assert'; import * as pgp from 'openpgp'; import * as sinon from 'sinon'; -import { MPSComms, MPSUtil, MPSTypes, MpsDerive, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; +import { EddsaMPSDkg, MPSComms, MPSUtil, MPSTypes, MpsDerive, MpsVrf, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; import { EddsaMPCv2DeriveRound1Request, EddsaMPCv2DeriveRound2Request, + EddsaMPCv2SignedMessage, KeyCurveEnum, KeyGenTypeEnum, MPCv2KeyGenStateEnum, @@ -15,11 +16,7 @@ import * as t from 'io-ts'; import { EddsaMPCv2Utils, BitGoBase, IBaseCoin, Keychain } from '../../../../../../src'; import { decodeWithCodec } from '../../../../../../src/bitgo/utils/codecs'; import { buildSafeMpcKeyEnvelopes } from '../../../../../../src/bitgo/utils/tss/keyShareEnvelope'; -import { - EddsaVrfMPCv2Utils, - deserializeVrfMessages, - serializeVrfMessages, -} from '../../../../../../src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2'; +import { EddsaVrfMPCv2Utils } from '../../../../../../src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2'; import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; import { generateGPGKeyPair } from '../../../../../../src/bitgo/utils/opengpgUtils'; @@ -214,34 +211,230 @@ describe('EdDSA MPCv2 VRF root material', function () { assert.strictEqual(addedKeychains[0].encryptedPrv, undefined); }); - it('round-trips opaque VRF blobs and keeps only the recipient p2p messages', function () { - const blob = serializeVrfMessages({ - broadcastMessages: [{ from: MPCv2PartiesEnum.BITGO, payload: new Uint8Array([1, 2, 3]) }], - p2pMessages: [ - { from: MPCv2PartiesEnum.BITGO, to: MPCv2PartiesEnum.USER, payload: new Uint8Array([4]) }, - { from: MPCv2PartiesEnum.BITGO, to: MPCv2PartiesEnum.BACKUP, payload: new Uint8Array([5]) }, - ], + describe('safe root keygen against the HSM contract', function () { + const KeyGenRequest = t.intersection([ + t.type({ enterprise: t.string, type: t.string, curveType: t.string, round: t.string }), + t.partial({ safeId: t.string, payload: t.UnknownRecord }), + ]); + const R1Payload = t.intersection([ + t.type({ + userGpgPublicKey: t.string, + backupGpgPublicKey: t.string, + userMsg1: EddsaMPCv2SignedMessage, + backupMsg1: EddsaMPCv2SignedMessage, + }), + t.type({ + userVrfMsg1: EddsaMPCv2SignedMessage, + backupVrfMsg1: EddsaMPCv2SignedMessage, + }), + ]); + const R2Payload = t.intersection([ + t.type({ sessionId: t.string, userMsg2: EddsaMPCv2SignedMessage, backupMsg2: EddsaMPCv2SignedMessage }), + t.type({ + userVrfMsg2: EddsaMPCv2SignedMessage, + backupVrfMsg2: EddsaMPCv2SignedMessage, + }), + ]); + + let bitgoPrivateKey: pgp.PrivateKey; + let bitgoPublicKey: pgp.Key; + + before(async function () { + const bitgoGpg = await generateGPGKeyPair('ed25519'); + bitgoPrivateKey = await pgp.readPrivateKey({ armoredKey: bitgoGpg.privateKey }); + bitgoPublicKey = await pgp.readKey({ armoredKey: bitgoGpg.publicKey }); }); - const forUser = deserializeVrfMessages(blob, MPCv2PartiesEnum.USER); - assert.deepStrictEqual(Buffer.from(forUser.broadcastMessages[0].payload), Buffer.from([1, 2, 3])); - assert.strictEqual(forUser.p2pMessages.length, 1); - assert.strictEqual(forUser.p2pMessages[0].to, MPCv2PartiesEnum.USER); - assert.deepStrictEqual(Buffer.from(forUser.p2pMessages[0].payload), Buffer.from([4])); + afterEach(function () { + sinon.restore(); + }); - const forBackup = deserializeVrfMessages(blob, MPCv2PartiesEnum.BACKUP); - assert.strictEqual(forBackup.p2pMessages.length, 1); - assert.strictEqual(forBackup.p2pMessages[0].to, MPCv2PartiesEnum.BACKUP); - }); + /** BitGo's side of the ceremony: signing DKG + VRF DKG, verifying every client signature like the HSM does. */ + async function keygenWithHsmPeer( + fault?: 'bitgo-vrf-msg1-signature' | 'bitgo-vrf-opening-signature' | 'missing-opening' + ) { + const { bitgo, baseCoin, post, encryptedInputs, addedKeychains } = createKeychainFixture(); + const utils = new EddsaVrfMPCv2Utils(bitgo, baseCoin); + sinon.stub(utils, 'getBitgoGpgPubkeyBasedOnFeatureFlags').resolves({ + mpcv2PublicKey: bitgoPublicKey, + eddsaMpcv2PublicKey: bitgoPublicKey, + redpallasMpcv2PublicKey: undefined, + }); - it('rejects malformed VRF DKG message blobs', function () { - assert.throws(() => deserializeVrfMessages('%%%', MPCv2PartiesEnum.USER), /Invalid VRF DKG message blob/); - const notAnArray = Buffer.from(JSON.stringify({ from: 0 })).toString('base64'); - assert.throws(() => deserializeVrfMessages(notAnArray, MPCv2PartiesEnum.USER), /VRF DKG message blob/); - const badParty = Buffer.from(JSON.stringify([{ from: 9, payload: Buffer.from([1]).toString('base64') }])).toString( - 'base64' - ); - assert.throws(() => deserializeVrfMessages(badParty, MPCv2PartiesEnum.USER), /VRF DKG message blob/); + const bitgoDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.BITGO); + const bitgoVrf = new MpsVrf.VrfDkg(3, 2, MPCv2PartiesEnum.BITGO); + const requests: Record[] = []; + let bitgoMsg2: MPSTypes.DeserializedMessage | undefined; + let userGpgKey: pgp.Key | undefined; + let backupGpgKey: pgp.Key | undefined; + let userCommitment: Uint8Array | undefined; + let backupCommitment: Uint8Array | undefined; + + const signAsBitgo = (payload: Uint8Array) => MPSComms.detachSignMpsMessage(payload, bitgoPrivateKey); + const encryptTo = async (recipient: pgp.Key, plaintext: Uint8Array): Promise => { + const { signature } = await signAsBitgo(plaintext); + const message = await pgp.encrypt({ + message: await pgp.createMessage({ binary: plaintext }), + encryptionKeys: recipient, + format: 'armored', + }); + return { + message, + signature: + fault === 'bitgo-vrf-opening-signature' ? (await signAsBitgo(new Uint8Array([9]))).signature : signature, + }; + }; + + post.callsFake(() => ({ + send: (rawBody: unknown) => ({ + result: async () => { + const body = decodeWithCodec(KeyGenRequest, rawBody, 'EdDSA keygen request'); + assert.strictEqual(body.type, KeyGenTypeEnum.MPCv2); + assert.strictEqual(body.curveType, KeyCurveEnum.EdDSA); + // WP only reads safeId in round 1; round 2 is routed by the stored session. + assert.strictEqual(body.safeId, body.round === MPCv2KeyGenStateEnum['MPCv2-R1'] ? 'safe-id' : undefined); + requests.push({ round: body.round, ...body.payload }); + + if (body.round === MPCv2KeyGenStateEnum['MPCv2-R1']) { + const payload = decodeWithCodec(R1Payload, body.payload, 'EdDSA keygen round 1'); + userGpgKey = await pgp.readKey({ armoredKey: payload.userGpgPublicKey }); + backupGpgKey = await pgp.readKey({ armoredKey: payload.backupGpgPublicKey }); + const [userPk, backupPk] = await Promise.all([ + MPSComms.extractEd25519PublicKey(userGpgKey), + MPSComms.extractEd25519PublicKey(backupGpgKey), + ]); + const [, bitgoSk] = await MPSComms.extractEd25519KeyPair(bitgoPrivateKey); + + const userMsg1 = await MPSComms.verifyMpsMessage(payload.userMsg1, userGpgKey); + const backupMsg1 = await MPSComms.verifyMpsMessage(payload.backupMsg1, backupGpgKey); + // The VRF messages must be signed by the same party key as userMsg1/backupMsg1. + userCommitment = new Uint8Array(await MPSComms.verifyMpsMessage(payload.userVrfMsg1, userGpgKey)); + backupCommitment = new Uint8Array(await MPSComms.verifyMpsMessage(payload.backupVrfMsg1, backupGpgKey)); + await assert.rejects(MPSComms.verifyMpsMessage(payload.userVrfMsg1, backupGpgKey)); + + await bitgoDkg.initDkg(bitgoSk, [userPk, backupPk]); + const bitgoRawMsg1 = bitgoDkg.getFirstMessage(); + [bitgoMsg2] = bitgoDkg.handleIncomingMessages([ + { from: MPCv2PartiesEnum.USER, payload: new Uint8Array(userMsg1) }, + { from: MPCv2PartiesEnum.BACKUP, payload: new Uint8Array(backupMsg1) }, + bitgoRawMsg1, + ]); + + const [bitgoVrfCommitment] = (await bitgoVrf.initDkg()).broadcastMessages; + const signedVrfMsg1 = await signAsBitgo(bitgoVrfCommitment.payload); + return { + sessionId: 'session-id', + bitgoMsg1: await signAsBitgo(bitgoRawMsg1.payload), + bitgoVrfMsg1: + fault === 'bitgo-vrf-msg1-signature' + ? { ...signedVrfMsg1, message: Buffer.from('tampered').toString('base64') } + : signedVrfMsg1, + }; + } + + assert.strictEqual(body.round, MPCv2KeyGenStateEnum['MPCv2-R2']); + const payload = decodeWithCodec(R2Payload, body.payload, 'EdDSA keygen round 2'); + assert.ok(userGpgKey && backupGpgKey && userCommitment && backupCommitment && bitgoMsg2); + assert.deepStrictEqual(Object.keys(payload).sort(), [ + 'backupMsg2', + 'backupVrfMsg2', + 'sessionId', + 'userMsg2', + 'userVrfMsg2', + ]); + + const userMsg2 = await MPSComms.verifyMpsMessage(payload.userMsg2, userGpgKey); + const backupMsg2 = await MPSComms.verifyMpsMessage(payload.backupMsg2, backupGpgKey); + const userOpening = new Uint8Array(await MPSComms.verifyMpsMessage(payload.userVrfMsg2, userGpgKey)); + const backupOpening = new Uint8Array(await MPSComms.verifyMpsMessage(payload.backupVrfMsg2, backupGpgKey)); + + bitgoDkg.handleIncomingMessages([ + { from: MPCv2PartiesEnum.USER, payload: new Uint8Array(userMsg2) }, + { from: MPCv2PartiesEnum.BACKUP, payload: new Uint8Array(backupMsg2) }, + bitgoMsg2, + ]); + + const openings = await bitgoVrf.handleIncomingMessages({ + broadcastMessages: [ + { from: MPCv2PartiesEnum.USER, payload: userCommitment }, + { from: MPCv2PartiesEnum.BACKUP, payload: backupCommitment }, + ], + p2pMessages: [], + }); + const toUser = openings.p2pMessages.find(({ to }) => to === MPCv2PartiesEnum.USER); + const toBackup = openings.p2pMessages.find(({ to }) => to === MPCv2PartiesEnum.BACKUP); + assert.ok(toUser && toBackup); + await bitgoVrf.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [ + { from: MPCv2PartiesEnum.USER, to: MPCv2PartiesEnum.BITGO, payload: userOpening }, + { from: MPCv2PartiesEnum.BACKUP, to: MPCv2PartiesEnum.BITGO, payload: backupOpening }, + ], + }); + + return { + sessionId: payload.sessionId, + commonPublicKeychain: bitgoDkg.getCommonKeychain(), + bitgoMsg2: await signAsBitgo(bitgoMsg2.payload), + ...(fault === 'missing-opening' + ? {} + : { bitgoVrfMsgToUser: await encryptTo(userGpgKey, toUser.payload) }), + bitgoVrfMsgToBackup: await encryptTo(backupGpgKey, toBackup.payload), + }; + }, + }), + })); + + await utils.createKeychains({ + passphrase: 'passphrase', + enterprise: 'enterprise-id', + safeId: 'safe-id', + }); + return { requests, encryptedInputs, addedKeychains }; + } + + it('signs VRF messages with the party keys and decrypts BitGo openings without handling the HSM VRF state', async function () { + const { requests, encryptedInputs, addedKeychains } = await keygenWithHsmPeer(); + + assert.deepStrictEqual( + requests.map(({ round }) => round), + [MPCv2KeyGenStateEnum['MPCv2-R1'], MPCv2KeyGenStateEnum['MPCv2-R2']] + ); + // WP keeps the HSM VRF state between rounds, so neither client round carries it. + for (const request of requests) { + assert.strictEqual('bitgoEncryptedVrfDkgState' in request, false); + } + + // Both client roots end up with a signing share and a VRF share, tagged with the safe. + assert.deepStrictEqual(addedKeychains.map(({ source }) => source).sort(), ['backup', 'bitgo', 'user']); + for (const keychain of addedKeychains) { + assert.strictEqual(keychain.safeId, 'safe-id'); + } + // User and backup each encrypt a full and a reduced envelope; every one carries a VRF share, and + // the two parties' shares differ. Order-independent: the roots are registered concurrently. + const vrfShares = new Set( + encryptedInputs.map((input) => { + const { vrf, prvKeyShare } = decodeVrfEnvelope(Buffer.from(input, 'base64')); + assert.ok(vrf.length > 0); + assert.ok(prvKeyShare.length > 0); + return Buffer.from(vrf).toString('hex'); + }) + ); + assert.strictEqual(encryptedInputs.length, 4); + assert.strictEqual(vrfShares.size, 2); + }); + + it('rejects a BitGo VRF round 1 message whose signature does not verify', async function () { + await assert.rejects(keygenWithHsmPeer('bitgo-vrf-msg1-signature'), /signature|verification|signed/i); + }); + + it('rejects a BitGo VRF opening whose signature does not verify', async function () { + await assert.rejects(keygenWithHsmPeer('bitgo-vrf-opening-signature'), /signature|verification|signed/i); + }); + + it('fails when the HSM omits a BitGo VRF opening in round 2', async function () { + await assert.rejects(keygenWithHsmPeer('missing-opening'), /VRF message to user/); + }); }); }); diff --git a/modules/sdk-lib-safes/package.json b/modules/sdk-lib-safes/package.json index 7a2df115ae..0ba79fa7b4 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.79.0", + "@bitgo/public-types": "6.83.0", "@bitgo/statics": "^59.22.0", "@bitgo/utxo-lib": "^11.24.4", "create-hmac": "^1.1.7", diff --git a/yarn.lock b/yarn.lock index 47712e8bc2..ba5a762d2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1021,21 +1021,10 @@ "@scure/base" "1.1.5" micro-eth-signer "0.7.2" -"@bitgo/public-types@6.79.0": - version "6.79.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.79.0.tgz#35ac66c1e62c3530f06d80f65a01ce5f23c70fa1" - integrity sha512-v9rJU1NofkcTSobdZ8WwMQrUGEi1zKAZi6HT4G9kQB8jOk8LzqX6ltlg4aoNmq5mDHCvlH9SFFJbqtalhtbg7A== - dependencies: - fp-ts "^2.0.0" - io-ts "npm:@bitgo-forks/io-ts@2.1.4" - io-ts-types "^0.5.16" - monocle-ts "^2.3.13" - newtype-ts "^0.3.5" - -"@bitgo/public-types@6.80.0": - version "6.80.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.80.0.tgz#5006827eedbde08c188cb31ff4f4ae37137586a5" - integrity sha512-u5fz4IhCZ9Zz34OFS5S323aZj53hosgcBwW5OkYICXsOuDqcw7VPm3dB45TlvA+ENvwRp9APTuQ790Y0ymFExA== +"@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== dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4"