diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index f50bd22b8b..0091c34cc0 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -410,13 +410,17 @@ export class Keychains implements IKeychains { throw new SafeMpcCeremonyUnsupportedError(this.baseCoin.getFamily()); } + // A safeId on a keygen ceremony selects the VRF variant, which additionally runs the + // VRF DKG alongside the signing DKG. Ordinary TSS wallet creation never sets safeId + // and keeps the plain MPCv2 flow. let MpcUtils; if (this.baseCoin.getMPCAlgorithm() === 'eddsa') { - MpcUtils = isMPCv2 ? EDDSAUtils.EddsaMPCv2Utils : EDDSAUtils.default; + MpcUtils = isMPCv2 + ? params.safeId + ? EDDSAUtils.EddsaVrfMPCv2Utils + : EDDSAUtils.EddsaMPCv2Utils + : EDDSAUtils.default; } else { - // A safeId on a keygen ceremony selects the VRF variant, which additionally runs the - // VRF DKG alongside the signing DKG. Ordinary TSS wallet creation never sets safeId - // and keeps the plain MPCv2 flow. MpcUtils = isMPCv2 ? params.safeId ? ECDSAUtils.EcdsaVrfMPCv2Utils diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 75396d0cf7..a42b35ebfa 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -402,7 +402,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { return { ...(await keychains.add(keychainParams)), reducedEncryptedPrv }; } - private async addUserKeychain( + protected async addUserKeychain( commonKeychain: string, privateMaterial: Buffer, reducedPrivateMaterial: Buffer, @@ -427,7 +427,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { ); } - private async addBackupKeychain( + protected async addBackupKeychain( commonKeychain: string, privateMaterial: Buffer, reducedPrivateMaterial: Buffer, @@ -451,7 +451,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { ); } - private async addBitgoKeychain(commonKeychain: string, safeId?: string): Promise { + protected async addBitgoKeychain(commonKeychain: string, safeId?: string): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.BITGO, commonKeychain, @@ -1080,7 +1080,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { // #region retrofit - private async getUserAndBackupSession(retrofit?: DecryptedRetrofitPayload): Promise<{ + protected async getUserAndBackupSession(retrofit?: DecryptedRetrofitPayload): Promise<{ userDkg: EddsaMPSDkg.DKG; backupDkg: EddsaMPSDkg.DKG; }> { diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts new file mode 100644 index 0000000000..5711d9944c --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts @@ -0,0 +1,316 @@ +import { DklsTypes, MPSComms, MpsVrf, type MPSTypes } from '@bitgo/sdk-lib-mpc'; +import { + MPCv2KeyGenStateEnum, + type EddsaMPCv2KeyGenRound1Response, + type EddsaMPCv2KeyGenRound2Response, +} from '@bitgo/public-types'; +import { encode } from 'cbor-x'; +import assert from 'assert'; +import * as t from 'io-ts'; +import * as pgp from 'openpgp'; +import { NonEmptyString } from 'io-ts-types'; + +import type { KeychainsTriplet } from '../../../baseCoin'; +import type { DecryptedRetrofitPayload } from '../../../keychain/iKeychains'; +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 { EddsaMPCv2Utils } from './eddsaMPCv2'; +import { KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; +import type { EddsaMPCv2VrfKeyGenResponseFields } from './typesEddsaMPCv2'; +import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2'; + +const VRF_KEY_ENVELOPE_VERSION = 1; + +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'); +} + +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')), + })), + }; +} + +/** + * Combines the signing keyshare with the VRF keyshare in the CBOR envelope used + * by safe MPC roots. The reduced envelope is used for reducedEncryptedPrv. + */ +export function buildVrfKeyEnvelopes( + privateMaterial: Buffer, + reducedPrivateMaterial: Buffer, + vrfKeyShare: Buffer +): { envelope: Buffer; reducedEnvelope: Buffer } { + const envelope = encode({ + version: VRF_KEY_ENVELOPE_VERSION, + prvKeyShare: new Uint8Array(privateMaterial), + vrf: new Uint8Array(vrfKeyShare), + }); + const reducedEnvelope = encode({ + version: VRF_KEY_ENVELOPE_VERSION, + prvKeyShare: new Uint8Array(reducedPrivateMaterial), + vrf: new Uint8Array(vrfKeyShare), + }); + return { envelope: Buffer.from(envelope), reducedEnvelope: Buffer.from(reducedEnvelope) }; +} + +/** + * EdDSA MPCv2 key generation for safe roots. The two-round MPS VRF DKG rides + * the existing MPCv2-R1/R2 payloads and is only selected when safeId is set. + */ +export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { + /** @inheritdoc */ + async createKeychains(params: { + passphrase: string; + enterprise: string; + originalPasscodeEncryptionCode?: string; + retrofit?: DecryptedRetrofitPayload; + webauthnInfo?: WebauthnKeyEncryptionInfo; + encryptionVersion?: EncryptionVersion; + safeId: string; + }): Promise { + const { userDkg, backupDkg } = await this.getUserAndBackupSession(params.retrofit); + const userVrfSession = new MpsVrf.VrfDkg(3, 2, MPCv2PartiesEnum.USER); + const backupVrfSession = new MpsVrf.VrfDkg(3, 2, MPCv2PartiesEnum.BACKUP); + + const userKeyPair = await generateGPGKeyPair('ed25519'); + const userGpgKey = await pgp.readPrivateKey({ armoredKey: userKeyPair.privateKey }); + const userGpgPublicKey = userKeyPair.publicKey; + const [userPk, userSk] = await MPSComms.extractEd25519KeyPair(userGpgKey); + + const backupKeyPair = await generateGPGKeyPair('ed25519'); + const backupGpgKey = await pgp.readPrivateKey({ armoredKey: backupKeyPair.privateKey }); + const backupGpgPublicKey = backupKeyPair.publicKey; + const [backupPk, backupSk] = await MPSComms.extractEd25519KeyPair(backupGpgKey); + + const { eddsaMpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true); + const bitgoPublicGpgKey = eddsaMpcv2PublicKey ?? this.bitgoEddsaMpcv2PublicGpgKey; + assert(bitgoPublicGpgKey, 'Failed to get BitGo EdDSA MPCv2 GPG public key'); + const bitgoPublicGpgKeyArmored = bitgoPublicGpgKey.armor(); + + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoEddsaMpcv2PubKey(bitgoPublicGpgKeyArmored), 'Invalid BitGo EdDSA MPCv2 GPG public key'); + } + + const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoPublicGpgKeyArmored }); + const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj); + + // #region round 1 + await userDkg.initDkg(userSk, [backupPk, bitgoPk]); + await backupDkg.initDkg(backupSk, [userPk, bitgoPk]); + + const userMsg1 = userDkg.getFirstMessage(); + const backupMsg1 = backupDkg.getFirstMessage(); + const userVrfMsg1 = await userVrfSession.initDkg(); + const backupVrfMsg1 = await backupVrfSession.initDkg(); + + const userSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgKey); + const backupSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg1.payload), backupGpgKey); + + assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); + assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); + + const round1Sender = KeyGenSenderForEnterprise( + this.bitgo, + params.enterprise, + params.safeId + ); + const { sessionId, bitgoMsg1, bitgoVrfMsg1 } = await round1Sender(MPCv2KeyGenStateEnum['MPCv2-R1'], { + userGpgPublicKey, + backupGpgPublicKey, + userMsg1: userSignedMsg1, + backupMsg1: backupSignedMsg1, + userVrfMsg1: serializeVrfMessages(userVrfMsg1), + backupVrfMsg1: serializeVrfMessages(backupVrfMsg1), + ...(params.retrofit?.walletId ? { walletId: params.retrofit.walletId } : {}), + }); + assert(bitgoVrfMsg1, 'BitGo VRF message 1 not found in round 1 response'); + // #endregion + + // #region round 2 + const bitgoRawMsg1Bytes = await MPSComms.verifyMpsMessage(bitgoMsg1, bitgoKeyObj); + const bitgoDeserializedMsg1: MPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.BITGO, + payload: new Uint8Array(bitgoRawMsg1Bytes), + }; + const round1Messages: MPSTypes.DeserializedMessages = [userMsg1, backupMsg1, bitgoDeserializedMsg1]; + + const userRound2Msgs = userDkg.handleIncomingMessages(round1Messages); + const backupRound2Msgs = backupDkg.handleIncomingMessages(round1Messages); + assert(userRound2Msgs.length === 1, 'User round 1 should produce exactly one round 2 message'); + assert(backupRound2Msgs.length === 1, 'Backup round 1 should produce exactly one round 2 message'); + + const userMsg2 = userRound2Msgs[0]; + const backupMsg2 = backupRound2Msgs[0]; + const userSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgKey); + const backupSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg2.payload), backupGpgKey); + + const userVrfMsg2 = await userVrfSession.handleIncomingMessages({ + broadcastMessages: [ + ...backupVrfMsg1.broadcastMessages, + ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.USER).broadcastMessages, + ], + p2pMessages: [], + }); + const backupVrfMsg2 = await backupVrfSession.handleIncomingMessages({ + broadcastMessages: [ + ...userVrfMsg1.broadcastMessages, + ...deserializeVrfMessages(bitgoVrfMsg1, MPCv2PartiesEnum.BACKUP).broadcastMessages, + ], + p2pMessages: [], + }); + + const round2Sender = KeyGenSenderForEnterprise( + this.bitgo, + params.enterprise + ); + const { + sessionId: sessionIdRound2, + commonPublicKeychain, + bitgoMsg2, + bitgoVrfMsg2, + } = await round2Sender(MPCv2KeyGenStateEnum['MPCv2-R2'], { + sessionId, + userMsg2: userSignedMsg2, + backupMsg2: backupSignedMsg2, + userVrfMsg2: serializeVrfMessages(userVrfMsg2), + backupVrfMsg2: serializeVrfMessages(backupVrfMsg2), + }); + 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'); + + // VRF finalizes locally after the second existing MPCv2 round. + 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, + ], + }); + 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, + ], + }); + // #endregion + + // #region keychain creation + const bitgoRawMsg2Bytes = await MPSComms.verifyMpsMessage(bitgoMsg2, bitgoKeyObj); + const bitgoDeserializedMsg2: MPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.BITGO, + payload: new Uint8Array(bitgoRawMsg2Bytes), + }; + const round2Messages: MPSTypes.DeserializedMessages = [userMsg2, backupMsg2, bitgoDeserializedMsg2]; + + const userFinalMsgs = userDkg.handleIncomingMessages(round2Messages); + const backupFinalMsgs = backupDkg.handleIncomingMessages(round2Messages); + assert(userFinalMsgs.length === 0, 'WASM round 2 should produce no output messages for user'); + assert(backupFinalMsgs.length === 0, 'WASM round 2 should produce no output messages for backup'); + + const userCommonKeychain = userDkg.getCommonKeychain(); + const backupCommonKeychain = backupDkg.getCommonKeychain(); + assert.equal( + userCommonKeychain, + commonPublicKeychain, + 'User computed keychain does not match BitGo common keychain' + ); + assert.equal( + backupCommonKeychain, + commonPublicKeychain, + 'Backup computed keychain does not match BitGo common keychain' + ); + + const { envelope: userEnvelope, reducedEnvelope: userReducedEnvelope } = buildVrfKeyEnvelopes( + userDkg.getKeyShare(), + userDkg.getReducedKeyShare(), + userVrfSession.getKeyShare() + ); + const { envelope: backupEnvelope, reducedEnvelope: backupReducedEnvelope } = buildVrfKeyEnvelopes( + backupDkg.getKeyShare(), + backupDkg.getReducedKeyShare(), + backupVrfSession.getKeyShare() + ); + + const userKeychainPromise = this.addUserKeychain( + userCommonKeychain, + userEnvelope, + userReducedEnvelope, + params.passphrase, + params.originalPasscodeEncryptionCode, + params.webauthnInfo, + params.encryptionVersion, + params.enterprise, + params.safeId + ); + const backupKeychainPromise = this.addBackupKeychain( + backupCommonKeychain, + backupEnvelope, + backupReducedEnvelope, + params.passphrase, + params.originalPasscodeEncryptionCode, + params.encryptionVersion, + params.enterprise, + params.safeId + ); + const bitgoKeychainPromise = this.addBitgoKeychain(commonPublicKeychain, params.safeId); + + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + userKeychainPromise, + backupKeychainPromise, + bitgoKeychainPromise, + ]); + // #endregion + + return { userKeychain, backupKeychain, bitgoKeychain }; + } +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts index c44fc5d192..355403828a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts @@ -15,6 +15,7 @@ export { } from '../baseTypes'; export * from './eddsaMPCv2'; +export * from './eddsaVrfMPCv2'; export * from './eddsaMPCv2KeyGenSender'; export * from './typesEddsaMPCv2'; export * from './SMC/utils'; 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 1b4541d5dd..3913491535 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts @@ -8,11 +8,29 @@ import { export const generateEddsaMPCv2KeyRequestBody = t.union([EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound2Request]); -export type GenerateEddsaMPCv2KeyRequestBody = t.TypeOf; +/** 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 const generateEddsaMPCv2KeyRequestResponse = t.union([ EddsaMPCv2KeyGenRound1Response, EddsaMPCv2KeyGenRound2Response, ]); -export type GenerateEddsaMPCv2KeyRequestResponse = t.TypeOf; +export type GenerateEddsaMPCv2KeyRequestResponse = t.TypeOf & + EddsaMPCv2VrfKeyGenResponseFields; 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 new file mode 100644 index 0000000000..5f7f9f550d --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts @@ -0,0 +1,203 @@ +import assert from 'assert'; +import { decode } from 'cbor-x'; +import * as t from 'io-ts'; + +import { EddsaMPCv2Utils, BitGoBase, IBaseCoin, Keychain } from '../../../../../../src'; +import { decodeWithCodec } from '../../../../../../src/bitgo/utils/codecs'; +import { + buildVrfKeyEnvelopes, + deserializeVrfMessages, + serializeVrfMessages, +} from '../../../../../../src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2'; +import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; + +const Uint8ArrayCodec = new t.Type( + 'Uint8Array', + (value): value is Uint8Array => value instanceof Uint8Array, + (value, context) => (value instanceof Uint8Array ? t.success(value) : t.failure(value, context)), + t.identity +); + +const VrfEnvelope = t.type({ + version: t.literal(1), + prvKeyShare: Uint8ArrayCodec, + vrf: Uint8ArrayCodec, +}); + +type AddedKeychainParams = { + source?: string; + encryptedPrv?: string; + safeId?: string; +}; + +function decodeVrfEnvelope(encoded: Buffer): t.TypeOf { + return decodeWithCodec(VrfEnvelope, decode(encoded), 'VRF key envelope'); +} + +describe('EdDSA MPCv2 VRF root material', function () { + it('encodes signing and VRF shares in both full and reduced envelopes', function () { + const privateMaterial = Buffer.from('signing-share'); + const reducedPrivateMaterial = Buffer.from('reduced-signing-share'); + const vrfKeyShare = Buffer.from('vrf-share'); + + const { envelope, reducedEnvelope } = buildVrfKeyEnvelopes(privateMaterial, reducedPrivateMaterial, vrfKeyShare); + const decodedEnvelope = decodeVrfEnvelope(envelope); + const decodedReducedEnvelope = decodeVrfEnvelope(reducedEnvelope); + + assert.deepStrictEqual(Buffer.from(decodedEnvelope.prvKeyShare), privateMaterial); + assert.deepStrictEqual(Buffer.from(decodedEnvelope.vrf), vrfKeyShare); + assert.deepStrictEqual(Buffer.from(decodedReducedEnvelope.prvKeyShare), reducedPrivateMaterial); + assert.deepStrictEqual(Buffer.from(decodedReducedEnvelope.vrf), vrfKeyShare); + }); + + it('keeps ordinary MPCv2 participant material as bare base64', async function () { + const encryptedInputs: string[] = []; + const addedKeychains: AddedKeychainParams[] = []; + const bitgo = { + encrypt: async (params: { input: string }): Promise => { + encryptedInputs.push(params.input); + return `encrypted:${params.input}`; + }, + } as unknown as BitGoBase; + const keychains = { + add: async (params: AddedKeychainParams): Promise => { + addedKeychains.push(params); + return { id: 'user-key' } as unknown as Keychain; + }, + }; + const baseCoin = { + keychains: () => keychains, + } as unknown as IBaseCoin; + const utils = new EddsaMPCv2Utils(bitgo, baseCoin); + const privateMaterial = Buffer.from('signing-share'); + const reducedPrivateMaterial = Buffer.from('reduced-signing-share'); + + await utils.createParticipantKeychain( + MPCv2PartiesEnum.USER, + 'common-keychain', + privateMaterial, + reducedPrivateMaterial, + 'passphrase' + ); + + assert.deepStrictEqual(encryptedInputs, [ + privateMaterial.toString('base64'), + reducedPrivateMaterial.toString('base64'), + ]); + assert.strictEqual(addedKeychains[0].encryptedPrv, `encrypted:${privateMaterial.toString('base64')}`); + assert.strictEqual(addedKeychains[0].safeId, undefined); + }); + + it('encrypts the VRF envelope and tags the keychain with safeId', async function () { + const encryptedInputs: string[] = []; + const addedKeychains: AddedKeychainParams[] = []; + const bitgo = { + encrypt: async (params: { input: string }): Promise => { + encryptedInputs.push(params.input); + return `encrypted:${params.input}`; + }, + } as unknown as BitGoBase; + const keychains = { + add: async (params: AddedKeychainParams): Promise => { + addedKeychains.push(params); + return { id: 'user-key' } as unknown as Keychain; + }, + }; + const baseCoin = { + keychains: () => keychains, + } as unknown as IBaseCoin; + const utils = new EddsaMPCv2Utils(bitgo, baseCoin); + const { envelope, reducedEnvelope } = buildVrfKeyEnvelopes( + Buffer.from('signing-share'), + Buffer.from('reduced-signing-share'), + Buffer.from('vrf-share') + ); + + await utils.createParticipantKeychain( + MPCv2PartiesEnum.USER, + 'common-keychain', + envelope, + reducedEnvelope, + 'passphrase', + undefined, + undefined, + undefined, + undefined, + 'safe-id' + ); + + assert.deepStrictEqual(encryptedInputs[0], envelope.toString('base64')); + assert.strictEqual(addedKeychains[0].safeId, 'safe-id'); + const decoded = decodeVrfEnvelope(Buffer.from(encryptedInputs[0], 'base64')); + assert.deepStrictEqual(Buffer.from(decoded.vrf), Buffer.from('vrf-share')); + }); + + it('tags the BitGo keychain with safeId and does not encrypt', async function () { + const encryptedInputs: string[] = []; + const addedKeychains: AddedKeychainParams[] = []; + const bitgo = { + encrypt: async (params: { input: string }): Promise => { + encryptedInputs.push(params.input); + return `encrypted:${params.input}`; + }, + } as unknown as BitGoBase; + const keychains = { + add: async (params: AddedKeychainParams): Promise => { + addedKeychains.push(params); + return { id: 'bitgo-key' } as unknown as Keychain; + }, + }; + const baseCoin = { + keychains: () => keychains, + } as unknown as IBaseCoin; + const utils = new EddsaMPCv2Utils(bitgo, baseCoin); + + await utils.createParticipantKeychain( + MPCv2PartiesEnum.BITGO, + 'common-keychain', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'safe-id' + ); + + assert.deepStrictEqual(encryptedInputs, []); + assert.strictEqual(addedKeychains[0].source, 'bitgo'); + assert.strictEqual(addedKeychains[0].safeId, 'safe-id'); + 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]) }, + ], + }); + + 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])); + + const forBackup = deserializeVrfMessages(blob, MPCv2PartiesEnum.BACKUP); + assert.strictEqual(forBackup.p2pMessages.length, 1); + assert.strictEqual(forBackup.p2pMessages[0].to, MPCv2PartiesEnum.BACKUP); + }); + + 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/); + }); +});