From eaaa472969330e491832a7637eabc9d19b136875 Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Thu, 16 Apr 2026 16:03:47 +0530 Subject: [PATCH] feat: implement EdDSA MPS DKG key gen orchestration TICKET: WCI-5 fix(sdk-core): address PR review comments on EdDSA MPCv2 key gen - Re-export EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise, and MPCv2 types from eddsa index to match ECDSA export pattern - Add reducedEncryptedPrv round-trip assertions to createParticipantKeychain tests to catch regressions in btoa browser-safe encoding path - Seed bitgoMPCv2PublicGpgKey in fallback test to fix fire-and-forget constructor race with beforeEach nock setup Co-Authored-By: Claude Sonnet 4.6 TICKET: WCI-5 --- .../tssUtils/eddsaMPCv2/createKeychains.ts | 425 ++++++++++++++++++ .../src/bitgo/utils/tss/eddsa/base.ts | 12 + .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 287 ++++++++++++ .../utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts | 20 + .../src/bitgo/utils/tss/eddsa/index.ts | 4 + .../bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts | 18 + .../src/tss/eddsa-mps/commsLayer.ts | 66 +++ .../sdk-lib-mpc/src/tss/eddsa-mps/index.ts | 1 + .../sdk-lib-mpc/src/tss/eddsa-mps/types.ts | 9 + 9 files changed, 842 insertions(+) create mode 100644 modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts create mode 100644 modules/sdk-core/src/bitgo/utils/tss/eddsa/base.ts create mode 100644 modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts create mode 100644 modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts create mode 100644 modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps/commsLayer.ts diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts new file mode 100644 index 0000000000..1ad03f1a37 --- /dev/null +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts @@ -0,0 +1,425 @@ +import * as assert from 'assert'; +import nock = require('nock'); +import * as openpgp from 'openpgp'; + +import { EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound2Request } from '@bitgo/public-types'; +import { TestableBG, TestBitGo } from '@bitgo/sdk-test'; +import { AddKeychainOptions, BaseCoin, common, ECDSAUtils, EDDSAUtils, Keychain, Wallet } from '@bitgo/sdk-core'; +import { EddsaMPSDkg, MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc'; +import { BitGo, BitgoGPGPublicKey } from '../../../../../../src'; + +const MPCv2PartiesEnum = ECDSAUtils.MPCv2PartiesEnum; + +describe('TSS EdDSA MPCv2 Utils:', async function () { + const coinName = 'sol'; + const walletId = '5b34252f1bf349930e34020a00000000'; + const enterpriseId = '6449153a6f6bc20006d66771cdbe15d3'; + + let bgUrl: string; + let tssUtils: EDDSAUtils.EddsaMPCv2Utils; + let wallet: Wallet; + let bitgo: TestableBG & BitGo; + let baseCoin: BaseCoin; + + let bitgoGpgKeyPair: openpgp.SerializedKeyPair & { revocationCertificate: string }; + let bitgoPrvKeyObj: openpgp.PrivateKey; + let constants: { mpc: { bitgoPublicKey: string; bitgoMPCv2PublicKey: string } }; + + beforeEach(async function () { + nock.cleanAll(); + await nockGetBitgoPublicKeyBasedOnFeatureFlags(coinName, enterpriseId, bitgoGpgKeyPair); + nock(bgUrl).get('/api/v1/client/constants').times(10).reply(200, { ttl: 3600, constants }); + }); + + before(async function () { + openpgp.config.rejectCurves = new Set(); + + bitgoGpgKeyPair = await openpgp.generateKey({ + userIDs: [{ name: 'bitgo', email: 'bitgo@test.com' }], + curve: 'ed25519', + format: 'armored', + }); + + bitgoPrvKeyObj = await openpgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); + + constants = { + mpc: { + bitgoPublicKey: bitgoGpgKeyPair.publicKey, + bitgoMPCv2PublicKey: bitgoGpgKeyPair.publicKey, + }, + }; + + bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + baseCoin = bitgo.coin(coinName); + bgUrl = common.Environments[bitgo.getEnv()].uri; + + const walletData = { + id: walletId, + enterprise: enterpriseId, + coin: coinName, + coinSpecific: {}, + multisigType: 'tss', + }; + wallet = new Wallet(bitgo, baseCoin, walletData); + tssUtils = new EDDSAUtils.EddsaMPCv2Utils(bitgo, baseCoin, wallet); + }); + + after(function () { + nock.cleanAll(); + }); + + describe('TSS key chains', async function () { + it('should generate TSS MPS keys', async function () { + const bitgoSession = new EddsaMPSDkg.DKG(3, 2, 2); + const bitgoState: { msg2?: MPSTypes.DeserializedMessage } = {}; + const round1Nock = await nockMPSKeyGenRound1(bitgoSession, bitgoState, 1); + const round2Nock = await nockMPSKeyGenRound2(bitgoSession, bitgoState, 1); + const addKeyNock = await nockAddKeyChain(coinName, 3); + + const params = { + passphrase: 'test', + enterprise: enterpriseId, + originalPasscodeEncryptionCode: '123456', + }; + + const { userKeychain, backupKeychain, bitgoKeychain } = await tssUtils.createKeychains(params); + + assert.ok(round1Nock.isDone()); + assert.ok(round2Nock.isDone()); + assert.ok(addKeyNock.isDone()); + + assert.ok(userKeychain); + assert.equal(userKeychain.source, 'user'); + assert.ok(userKeychain.commonKeychain); + assert.ok(userKeychain.encryptedPrv); + assert.ok(bitgo.decrypt({ input: userKeychain.encryptedPrv, password: params.passphrase })); + + assert.ok(backupKeychain); + assert.equal(backupKeychain.source, 'backup'); + assert.ok(backupKeychain.commonKeychain); + assert.ok(backupKeychain.encryptedPrv); + assert.ok(bitgo.decrypt({ input: backupKeychain.encryptedPrv, password: params.passphrase })); + + assert.ok(bitgoKeychain); + assert.equal(bitgoKeychain.source, 'bitgo'); + assert.ok(bitgoKeychain.commonKeychain); + + assert.equal(userKeychain.commonKeychain, backupKeychain.commonKeychain); + assert.equal(userKeychain.commonKeychain, bitgoKeychain.commonKeychain); + }); + + it('should create TSS key chains', async function () { + const fakeCommonKeychain = 'a'.repeat(64); + + const nockPromises = [ + nockKeychain({ + coin: coinName, + keyChain: { id: '1', pub: '1', type: 'tss', source: 'user', reducedEncryptedPrv: '' }, + source: 'user', + }), + nockKeychain({ + coin: coinName, + keyChain: { id: '2', pub: '2', type: 'tss', source: 'backup', reducedEncryptedPrv: '' }, + source: 'backup', + }), + nockKeychain({ + coin: coinName, + keyChain: { id: '3', pub: '3', type: 'tss', source: 'bitgo', reducedEncryptedPrv: '' }, + source: 'bitgo', + }), + ]; + const [nockedUserKeychain, nockedBackupKeychain, nockedBitGoKeychain] = await Promise.all(nockPromises); + + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + tssUtils.createParticipantKeychain( + MPCv2PartiesEnum.USER, + fakeCommonKeychain, + Buffer.from('userPrivate'), + Buffer.from('userReduced'), + 'passphrase' + ), + tssUtils.createParticipantKeychain( + MPCv2PartiesEnum.BACKUP, + fakeCommonKeychain, + Buffer.from('backupPrivate'), + Buffer.from('backupReduced'), + 'passphrase' + ), + tssUtils.createParticipantKeychain(MPCv2PartiesEnum.BITGO, fakeCommonKeychain), + ]); + + assert.ok(userKeychain); + assert.equal(bitgoKeychain.source, 'bitgo'); + + assert.equal(userKeychain.id, nockedUserKeychain.id); + assert.equal(backupKeychain.id, nockedBackupKeychain.id); + assert.equal(bitgoKeychain.id, nockedBitGoKeychain.id); + + ({ ...userKeychain, reducedEncryptedPrv: '' }).should.deepEqual(nockedUserKeychain); + ({ ...backupKeychain, reducedEncryptedPrv: '' }).should.deepEqual(nockedBackupKeychain); + ({ ...bitgoKeychain, reducedEncryptedPrv: '' }).should.deepEqual(nockedBitGoKeychain); + + // reducedEncryptedPrv must round-trip: decrypting with the passphrase should recover + // the browser-safe btoa encoding of the original reduced private material. + const encodeReduced = (buf: Buffer) => btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)))); + + assert.ok(userKeychain.reducedEncryptedPrv); + assert.equal( + bitgo.decrypt({ input: userKeychain.reducedEncryptedPrv, password: 'passphrase' }), + encodeReduced(Buffer.from('userReduced')) + ); + assert.ok(backupKeychain.reducedEncryptedPrv); + assert.equal( + bitgo.decrypt({ input: backupKeychain.reducedEncryptedPrv, password: 'passphrase' }), + encodeReduced(Buffer.from('backupReduced')) + ); + }); + + it('should reject when BitGo PGP signature on round 1 response is invalid', async function () { + nock(bgUrl) + .post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R1') + .once() + .reply(200, { + sessionId: 'bad-session', + bitgoMsg1: { + message: Buffer.from('garbage').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\nFAKE\n-----END PGP SIGNATURE-----', + }, + }); + + await assert.rejects(tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId })); + }); + + it('should reject when BitGo PGP signature on round 2 response is invalid', async function () { + const bitgoSession = new EddsaMPSDkg.DKG(3, 2, 2); + const bitgoState: { msg2?: MPSTypes.DeserializedMessage } = {}; + await nockMPSKeyGenRound1(bitgoSession, bitgoState, 1); + + nock(bgUrl) + .post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2') + .once() + .reply(200, { + sessionId: 'test-session-id', + commonPublicKey: 'a'.repeat(64), + bitgoMsg2: { + message: Buffer.from('garbage').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\nFAKE\n-----END PGP SIGNATURE-----', + }, + }); + + await assert.rejects(tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId })); + }); + + it('should reject when session IDs from round 1 and round 2 do not match', async function () { + const bitgoSession = new EddsaMPSDkg.DKG(3, 2, 2); + const bitgoState: { msg2?: MPSTypes.DeserializedMessage } = {}; + await nockMPSKeyGenRound1(bitgoSession, bitgoState, 1); + + nock(bgUrl) + .post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2') + .once() + .reply(200, { + sessionId: 'different-session-id', + commonPublicKey: 'a'.repeat(64), + bitgoMsg2: { message: '', signature: '' }, + }); + + await assert.rejects( + tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }), + /Round 1 and round 2 session IDs do not match/ + ); + }); + + it('should reject when commonPublicKey from BitGo does not match the locally computed key', async function () { + const bitgoSession = new EddsaMPSDkg.DKG(3, 2, 2); + const bitgoState: { msg2?: MPSTypes.DeserializedMessage } = {}; + await nockMPSKeyGenRound1(bitgoSession, bitgoState, 1); + + nock(bgUrl) + .post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2') + .once() + .reply(200, async (_uri, { payload }: { payload: EddsaMPCv2KeyGenRound2Request }) => { + const { userMsg2, backupMsg2 } = payload; + assert.ok(bitgoState.msg2, 'BitGo round-2 message missing — round-1 nock must run first'); + + const userDeserMsg2: MPSTypes.DeserializedMessage = { + from: 0, + payload: new Uint8Array(Buffer.from(userMsg2.message, 'base64')), + }; + const backupDeserMsg2: MPSTypes.DeserializedMessage = { + from: 1, + payload: new Uint8Array(Buffer.from(backupMsg2.message, 'base64')), + }; + bitgoSession.handleIncomingMessages([userDeserMsg2, backupDeserMsg2, bitgoState.msg2]); + + return { + sessionId: 'test-session-id', + commonPublicKey: 'fakefakeee'.repeat(8), // mutated — will not match user/backup computed key + bitgoMsg2: await MPSComms.detachSignMpsMessage(Buffer.from(bitgoState.msg2.payload), bitgoPrvKeyObj), + }; + }); + + await assert.rejects( + tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }), + /does not match BitGo common public key/ + ); + }); + + it('should reject when BitGo GPG public key does not match known keys in prod/test envs', async function () { + // Use a staging env so envRequiresBitgoPubGpgKeyConfig returns true + const stagingBitgo = TestBitGo.decorate(BitGo, { env: 'staging' }); + stagingBitgo.initializeTestVars(); + const stagingBaseCoin = stagingBitgo.coin(coinName); + const stagingBgUrl = common.Environments[stagingBitgo.getEnv()].uri; + const stagingWallet = new Wallet(stagingBitgo, stagingBaseCoin, { + id: walletId, + enterprise: enterpriseId, + coin: coinName, + coinSpecific: {}, + multisigType: 'tss', + }); + const stagingTssUtils = new EDDSAUtils.EddsaMPCv2Utils(stagingBitgo, stagingBaseCoin, stagingWallet); + + // Return a key that is NOT in the hardcoded BitGo MPC v2 key list + nock(stagingBgUrl).get(`/api/v2/${coinName}/tss/pubkey`).query({ enterpriseId }).reply(200, { + name: 'irrelevant', + publicKey: bitgoGpgKeyPair.publicKey, + mpcv2PublicKey: bitgoGpgKeyPair.publicKey, + enterpriseId, + }); + nock(stagingBgUrl).get('/api/v1/client/constants').reply(200, { ttl: 3600, constants }); + + await assert.rejects( + stagingTssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }), + /Invalid BitGo GPG public key/ + ); + }); + }); + + // --------------------------------------------------------------------------- + // Nock helpers + // --------------------------------------------------------------------------- + + async function nockGetBitgoPublicKeyBasedOnFeatureFlags( + coin: string, + enterprise: string, + bitgoKeyPair: openpgp.SerializedKeyPair + ): Promise { + const response: BitgoGPGPublicKey = { + name: 'irrelevant', + publicKey: bitgoKeyPair.publicKey, + mpcv2PublicKey: bitgoKeyPair.publicKey, + enterpriseId: enterprise, + }; + nock(bgUrl).get(`/api/v2/${coin}/tss/pubkey`).query({ enterpriseId: enterprise }).reply(200, response); + return response; + } + + async function nockMPSKeyGenRound1( + bitgoSession: EddsaMPSDkg.DKG, + bitgoState: { msg2?: MPSTypes.DeserializedMessage }, + times = 1 + ) { + return nock(bgUrl) + .post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R1') + .times(times) + .reply(200, async (_uri, { payload }: { payload: EddsaMPCv2KeyGenRound1Request }) => { + const { userGpgPublicKey, backupGpgPublicKey, userMsg1, backupMsg1 } = payload; + + openpgp.config.rejectCurves = new Set(); + const userPubKeyObj = await openpgp.readKey({ armoredKey: userGpgPublicKey }); + const backupPubKeyObj = await openpgp.readKey({ armoredKey: backupGpgPublicKey }); + + const userPk = await MPSComms.extractEd25519PublicKey(userPubKeyObj); + const backupPk = await MPSComms.extractEd25519PublicKey(backupPubKeyObj); + const [, bitgoSk] = await MPSComms.extractEd25519KeyPair(bitgoPrvKeyObj); + + bitgoSession.initDkg(bitgoSk, [userPk, backupPk]); + const bitgoRawMsg1 = bitgoSession.getFirstMessage(); + + await MPSComms.verifyMpsMessage(userMsg1, userPubKeyObj); + await MPSComms.verifyMpsMessage(backupMsg1, backupPubKeyObj); + + // Process all 3 round-1 messages (including BitGo's own) to advance state and produce bitgoMsg2 + const userDeserMsg1: MPSTypes.DeserializedMessage = { + from: 0, + payload: new Uint8Array(Buffer.from(userMsg1.message, 'base64')), + }; + const backupDeserMsg1: MPSTypes.DeserializedMessage = { + from: 1, + payload: new Uint8Array(Buffer.from(backupMsg1.message, 'base64')), + }; + const [bitgoRawMsg2] = bitgoSession.handleIncomingMessages([userDeserMsg1, backupDeserMsg1, bitgoRawMsg1]); + bitgoState.msg2 = bitgoRawMsg2; + + return { + sessionId: 'test-session-id', + bitgoMsg1: await MPSComms.detachSignMpsMessage(Buffer.from(bitgoRawMsg1.payload), bitgoPrvKeyObj), + }; + }); + } + + async function nockMPSKeyGenRound2( + bitgoSession: EddsaMPSDkg.DKG, + bitgoState: { msg2?: MPSTypes.DeserializedMessage }, + times = 1 + ) { + return nock(bgUrl) + .post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2') + .times(times) + .reply(200, async (_uri, { payload }: { payload: EddsaMPCv2KeyGenRound2Request }) => { + const { sessionId, userMsg2, backupMsg2 } = payload; + + openpgp.config.rejectCurves = new Set(); + + assert.ok(bitgoState.msg2, 'BitGo round-2 message missing — round-1 nock must run first'); + + const userDeserMsg2: MPSTypes.DeserializedMessage = { + from: 0, + payload: new Uint8Array(Buffer.from(userMsg2.message, 'base64')), + }; + const backupDeserMsg2: MPSTypes.DeserializedMessage = { + from: 1, + payload: new Uint8Array(Buffer.from(backupMsg2.message, 'base64')), + }; + + // Complete DKG with all 3 round-2 messages (user, backup, and BitGo's own msg2) + bitgoSession.handleIncomingMessages([userDeserMsg2, backupDeserMsg2, bitgoState.msg2]); + + return { + sessionId, + commonPublicKey: bitgoSession.getSharePublicKey().toString('hex'), + bitgoMsg2: await MPSComms.detachSignMpsMessage(Buffer.from(bitgoState.msg2.payload), bitgoPrvKeyObj), + }; + }); + } + + async function nockKeychain( + params: { coin: string; keyChain: Keychain; source: 'user' | 'backup' | 'bitgo' }, + times = 1 + ): Promise { + nock(bgUrl) + .post(`/api/v2/${params.coin}/key`, (body) => body.keyType === 'tss' && body.source === params.source) + .times(times) + .reply(200, params.keyChain); + return params.keyChain; + } + + async function nockAddKeyChain(coin: string, times = 1) { + return nock(bgUrl) + .post(`/api/v2/${coin}/key`, (body) => body.keyType === 'tss' && body.isMPCv2) + .times(times) + .reply(200, async (_uri, requestBody: AddKeychainOptions) => { + const key = { + id: requestBody.source, + source: requestBody.source, + type: requestBody.keyType, + commonKeychain: requestBody.commonKeychain, + encryptedPrv: requestBody.encryptedPrv, + }; + nock(bgUrl).get(`/api/v2/${coin}/key/${requestBody.source}`).reply(200, key); + return key; + }); + } +}); diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/base.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/base.ts new file mode 100644 index 0000000000..5f7da3a20c --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/base.ts @@ -0,0 +1,12 @@ +import { IBaseCoin } from '../../../baseCoin'; +import baseTSSUtils from '../baseTSSUtils'; +import { KeyShare } from './types'; +import { BitGoBase } from '../../../bitgoBase'; +import { IWallet } from '../../../wallet'; + +export class BaseEddsaUtils extends baseTSSUtils { + constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, wallet?: IWallet) { + super(bitgo, baseCoin, wallet); + this.setBitgoGpgPubKey(bitgo); + } +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts new file mode 100644 index 0000000000..6e5534bf8f --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -0,0 +1,287 @@ +import assert from 'assert'; +import * as pgp from 'openpgp'; +import { NonEmptyString } from 'io-ts-types'; +import { + EddsaMPCv2KeyGenRound1Request, + EddsaMPCv2KeyGenRound1Response, + EddsaMPCv2KeyGenRound2Request, + EddsaMPCv2KeyGenRound2Response, + MPCv2KeyGenStateEnum, + MPCv2PartyFromStringOrNumber, +} from '@bitgo/public-types'; +import { EddsaMPSDkg, MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc'; +import { KeychainsTriplet } from '../../../baseCoin'; +import { AddKeychainOptions, Keychain, KeyType } from '../../../keychain'; +import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys'; +import { generateGPGKeyPair } from '../../opengpgUtils'; +import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2'; +import { BaseEddsaUtils } from './base'; +import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; + +export class EddsaMPCv2Utils extends BaseEddsaUtils { + /** @inheritdoc */ + async createKeychains(params: { + passphrase: string; + enterprise: string; + originalPasscodeEncryptionCode?: string; + }): Promise { + 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); + + // Get the BitGo public key based on user/enterprise feature flags; + // fall back to the hardcoded MPCv2 public key from constants. + const bitgoPublicGpgKey = + (await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true)) ?? this.bitgoMPCv2PublicGpgKey; + const bitgoPublicGpgKeyArmored = bitgoPublicGpgKey.armor(); + + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoMpcPubKey(bitgoPublicGpgKeyArmored, 'mpcv2'), 'Invalid BitGo GPG public key'); + } + + const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoPublicGpgKeyArmored }); + const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj); + + // Create DKG sessions for user (party 0) and backup (party 1) + const userDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER); + const backupDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.BACKUP); + + // #region round 1 + userDkg.initDkg(userSk, [backupPk, bitgoPk]); + backupDkg.initDkg(backupSk, [userPk, bitgoPk]); + + const userMsg1 = userDkg.getFirstMessage(); + const backupMsg1 = backupDkg.getFirstMessage(); + + 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 { sessionId, bitgoMsg1 } = await this.sendKeyGenerationRound1(params.enterprise, { + userGpgPublicKey, + backupGpgPublicKey, + userMsg1: userSignedMsg1, + backupMsg1: backupSignedMsg1, + }); + // #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 { + sessionId: sessionIdRound2, + commonPublicKey, + bitgoMsg2, + } = await this.sendKeyGenerationRound2(params.enterprise, { + sessionId, + userMsg2: userSignedMsg2, + backupMsg2: backupSignedMsg2, + }); + // #endregion + + // #region keychain creation + assert.equal(sessionId, sessionIdRound2, 'Round 1 and round 2 session IDs do not match'); + + 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 userCommonKey = userDkg.getSharePublicKey().toString('hex'); + const backupCommonKey = backupDkg.getSharePublicKey().toString('hex'); + + assert.equal(userCommonKey, commonPublicKey, 'User computed public key does not match BitGo common public key'); + assert.equal(backupCommonKey, commonPublicKey, 'Backup computed public key does not match BitGo common public key'); + + const userPrivateMaterial = userDkg.getKeyShare(); + const backupPrivateMaterial = backupDkg.getKeyShare(); + const userReducedPrivateMaterial = userDkg.getReducedKeyShare(); + const backupReducedPrivateMaterial = backupDkg.getReducedKeyShare(); + + const userKeychainPromise = this.addUserKeychain( + commonPublicKey, + userPrivateMaterial, + userReducedPrivateMaterial, + params.passphrase, + params.originalPasscodeEncryptionCode + ); + const backupKeychainPromise = this.addBackupKeychain( + commonPublicKey, + backupPrivateMaterial, + backupReducedPrivateMaterial, + params.passphrase, + params.originalPasscodeEncryptionCode + ); + const bitgoKeychainPromise = this.addBitgoKeychain(commonPublicKey); + + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + userKeychainPromise, + backupKeychainPromise, + bitgoKeychainPromise, + ]); + // #endregion + + return { + userKeychain, + backupKeychain, + bitgoKeychain, + }; + } + + // #region keychain utils + async createParticipantKeychain( + participantIndex: MPCv2PartyFromStringOrNumber, + commonKeychain: string, + privateMaterial?: Buffer, + reducedPrivateMaterial?: Buffer, + passphrase?: string, + originalPasscodeEncryptionCode?: string + ): Promise { + let source: string; + let encryptedPrv: string | undefined = undefined; + let reducedEncryptedPrv: string | undefined = undefined; + + switch (participantIndex) { + case MPCv2PartiesEnum.USER: + case MPCv2PartiesEnum.BACKUP: + source = participantIndex === MPCv2PartiesEnum.USER ? 'user' : 'backup'; + assert(privateMaterial, `Private material is required for ${source} keychain`); + assert(reducedPrivateMaterial, `Reduced private material is required for ${source} keychain`); + assert(passphrase, `Passphrase is required for ${source} keychain`); + encryptedPrv = this.bitgo.encrypt({ + input: privateMaterial.toString('base64'), + password: passphrase, + }); + // Encrypts the CBOR-encoded ReducedKeyShare (which contains the party's public + // key) with the wallet passphrase. The result is stored as reducedEncryptedPrv + // on the key card QR code and represents a second copy of key material + // beyond the server-stored encryptedPrv. + reducedEncryptedPrv = this.bitgo.encrypt({ + // Buffer.toString('base64') can not be used here as it does not work on the browser. + // The browser deals with a Buffer as Uint8Array, therefore in the browser .toString('base64') just creates a comma separated string of the array values. + input: btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))), + password: passphrase, + }); + break; + case MPCv2PartiesEnum.BITGO: + source = 'bitgo'; + break; + default: + throw new Error('Invalid participant index'); + } + + const keychainParams: AddKeychainOptions = { + source, + keyType: 'tss' as KeyType, + commonKeychain, + encryptedPrv, + originalPasscodeEncryptionCode, + isMPCv2: true, + }; + + const keychains = this.baseCoin.keychains(); + return { ...(await keychains.add(keychainParams)), reducedEncryptedPrv }; + } + + private async addUserKeychain( + commonKeychain: string, + privateMaterial: Buffer, + reducedPrivateMaterial: Buffer, + passphrase: string, + originalPasscodeEncryptionCode?: string + ): Promise { + return this.createParticipantKeychain( + MPCv2PartiesEnum.USER, + commonKeychain, + privateMaterial, + reducedPrivateMaterial, + passphrase, + originalPasscodeEncryptionCode + ); + } + + private async addBackupKeychain( + commonKeychain: string, + privateMaterial: Buffer, + reducedPrivateMaterial: Buffer, + passphrase: string, + originalPasscodeEncryptionCode?: string + ): Promise { + return this.createParticipantKeychain( + MPCv2PartiesEnum.BACKUP, + commonKeychain, + privateMaterial, + reducedPrivateMaterial, + passphrase, + originalPasscodeEncryptionCode + ); + } + + private async addBitgoKeychain(commonKeychain: string): Promise { + return this.createParticipantKeychain(MPCv2PartiesEnum.BITGO, commonKeychain); + } + // #endregion + + async sendKeyGenerationRound1( + enterprise: string, + payload: EddsaMPCv2KeyGenRound1Request + ): Promise { + return this.sendKeyGenerationRound1BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise), payload); + } + + async sendKeyGenerationRound1BySender( + senderFn: EddsaMPCv2KeyGenSendFn, + payload: EddsaMPCv2KeyGenRound1Request + ): Promise { + return senderFn(MPCv2KeyGenStateEnum['MPCv2-R1'], payload); + } + + async sendKeyGenerationRound2( + enterprise: string, + payload: EddsaMPCv2KeyGenRound2Request + ): Promise { + return this.sendKeyGenerationRound2BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise), payload); + } + + async sendKeyGenerationRound2BySender( + senderFn: EddsaMPCv2KeyGenSendFn, + payload: EddsaMPCv2KeyGenRound2Request + ): Promise { + return senderFn(MPCv2KeyGenStateEnum['MPCv2-R2'], payload); + } +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts new file mode 100644 index 0000000000..b05197d813 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts @@ -0,0 +1,20 @@ +import { KeyCurveEnum, KeyGenTypeEnum, MPCv2KeyGenState } from '@bitgo/public-types'; +import { BitGoBase } from '../../../bitgoBase'; +import { GenerateEddsaMPCv2KeyRequestBody, GenerateEddsaMPCv2KeyRequestResponse } from './typesEddsaMPCv2'; + +export type EddsaMPCv2KeyGenSendFn = ( + round: MPCv2KeyGenState, + payload: GenerateEddsaMPCv2KeyRequestBody +) => Promise; + +export function KeyGenSenderForEnterprise( + bitgo: BitGoBase, + enterprise: string +): EddsaMPCv2KeyGenSendFn { + return (round, payload) => { + return bitgo + .post(bitgo.url('/mpc/generatekey', 2)) + .send({ enterprise, type: KeyGenTypeEnum.MPCv2, keyCurve: KeyCurveEnum.EdDSA, round, payload }) + .result(); + }; +} 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 b3df2aa85c..b70b7f4470 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts @@ -13,3 +13,7 @@ export { SignatureShareType, TxRequest, } from '../baseTypes'; + +export * from './eddsaMPCv2'; +export * from './eddsaMPCv2KeyGenSender'; +export * from './typesEddsaMPCv2'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts new file mode 100644 index 0000000000..1b4541d5dd --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts @@ -0,0 +1,18 @@ +import * as t from 'io-ts'; +import { + EddsaMPCv2KeyGenRound1Request, + EddsaMPCv2KeyGenRound1Response, + EddsaMPCv2KeyGenRound2Request, + EddsaMPCv2KeyGenRound2Response, +} from '@bitgo/public-types'; + +export const generateEddsaMPCv2KeyRequestBody = t.union([EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound2Request]); + +export type GenerateEddsaMPCv2KeyRequestBody = t.TypeOf; + +export const generateEddsaMPCv2KeyRequestResponse = t.union([ + EddsaMPCv2KeyGenRound1Response, + EddsaMPCv2KeyGenRound2Response, +]); + +export type GenerateEddsaMPCv2KeyRequestResponse = t.TypeOf; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/commsLayer.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/commsLayer.ts new file mode 100644 index 0000000000..055acba856 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/commsLayer.ts @@ -0,0 +1,66 @@ +import * as pgp from 'openpgp'; +import { MPSSignedMessage } from './types'; + +/** + * PGP detached-signs raw WASM bytes with the given private key. + * Returns an MPSSignedMessage with the base64-encoded payload and armored signature. + */ +export async function detachSignMpsMessage( + rawBytes: Buffer | Uint8Array, + signingKey: pgp.PrivateKey +): Promise { + const signature = await pgp.sign({ + message: await pgp.createMessage({ binary: rawBytes }), + signingKeys: signingKey, + detached: true, + }); + return { + message: Buffer.from(rawBytes).toString('base64'), + signature: signature as string, + }; +} + +/** + * Verifies a PGP detached signature on an MPSSignedMessage and returns the decoded raw bytes. + * Throws if the signature is invalid or not present. + */ +export async function verifyMpsMessage(msg: MPSSignedMessage, verificationKey: pgp.Key): Promise { + const rawBytes = Buffer.from(msg.message, 'base64'); + const result = await pgp.verify({ + message: await pgp.createMessage({ binary: rawBytes }), + signature: await pgp.readSignature({ armoredSignature: msg.signature }), + verificationKeys: verificationKey, + expectSigned: true, + }); + await result.signatures[0].verified; + return rawBytes; +} + +/** + * Extracts the X25519 public key bytes from a GPG key's encryption subkey. + * Works for both public-only and private keys — use this for third-party keys (e.g. BitGo's). + * + * @param key - A GPG key with an X25519 encryption subkey. + * @returns 32-byte X25519 public key. + */ +export async function extractEd25519PublicKey(key: pgp.Key): Promise { + const { keyPacket } = await key.getEncryptionKey(); + return Buffer.from((keyPacket.publicParams as { Q: Uint8Array }).Q).subarray(1); +} + +/** + * Extracts the X25519 public and private key bytes from a GPG ed25519 private key's + * encryption subkey. Encapsulates the keyPacket internals and the little-endian scalar + * reversal in one place so sdk-core only deals with plain Buffers. + * + * @param privateKey - An ed25519 GPG private key with an X25519 encryption subkey. + * @returns [pk, sk] — 32-byte X25519 public key and private scalar. + */ +export async function extractEd25519KeyPair(privateKey: pgp.PrivateKey): Promise<[Buffer, Buffer]> { + const encKey = await privateKey.getEncryptionKey(); + const pk = Buffer.from((encKey.keyPacket.publicParams as { Q: Uint8Array }).Q).subarray(1); + const sk = Buffer.from( + (encKey.keyPacket as unknown as { privateParams: { d: Uint8Array } }).privateParams.d + ).reverse(); + return [pk, sk]; +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts index c3f103fc2d..eadfa9e80f 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts @@ -1,3 +1,4 @@ export * as EddsaMPSDkg from './dkg'; export * as MPSUtil from './util'; export * as MPSTypes from './types'; +export * as MPSComms from './commsLayer'; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts index 60907d27d5..3392793f76 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts @@ -55,6 +55,15 @@ export function deserializeMessages(msgs: SerializedMessages): DeserializedMessa return msgs.map(deserializeMessage); } +/** A PGP detached-signed message by a party. + * `message` is the raw payload encoded as base64. + * `signature` is an armored PGP detached signature over those bytes. + */ +export interface MPSSignedMessage { + message: string; + signature: string; +} + export function getDecodedReducedKeyShare(reducedKeyShare: Buffer | Uint8Array): EddsaReducedKeyShare { const decoded = ReducedKeyShareType.decode(decode(reducedKeyShare)); if (isLeft(decoded)) {