diff --git a/modules/sdk-core/src/bitgo/tss/common.ts b/modules/sdk-core/src/bitgo/tss/common.ts index 22a34048e6..0a294980d6 100644 --- a/modules/sdk-core/src/bitgo/tss/common.ts +++ b/modules/sdk-core/src/bitgo/tss/common.ts @@ -131,6 +131,8 @@ export async function sendSignatureShareV2( let type = ''; if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'ecdsa') { type = 'ecdsaMpcV2'; + } else if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'eddsa') { + type = 'eddsaMpcV2'; } else if (multisigTypeVersion === undefined && mpcAlgorithm === 'eddsa') { type = 'eddsaMpcV1'; } diff --git a/modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts new file mode 100644 index 0000000000..6e5a1970a6 --- /dev/null +++ b/modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts @@ -0,0 +1,126 @@ +import assert from 'assert'; +import * as openpgp from 'openpgp'; +import { MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc'; +import { + EddsaMPCv2SignatureShareRound1Input, + EddsaMPCv2SignatureShareRound1Output, + EddsaMPCv2SignatureShareRound2Input, + EddsaMPCv2SignatureShareRound2Output, + EddsaMPCv2SignatureShareRound3Input, +} from '@bitgo/public-types'; +import { SignatureShareRecord, SignatureShareType } from '../../utils/tss/baseTypes'; +import { MPCv2PartiesEnum } from '../../utils/tss/ecdsa/typesMPCv2'; + +function partyIdToSignatureShareType(partyId: 0 | 1 | 2): SignatureShareType { + assert(partyId === 0 || partyId === 1 || partyId === 2, 'Invalid partyId for EdDSA MPCv2 signing'); + switch (partyId) { + case 0: + return SignatureShareType.USER; + case 1: + return SignatureShareType.BACKUP; + case 2: + return SignatureShareType.BITGO; + } +} + +/** + * Builds the round-1 signature share record. + * + * PGP-signs the WASM round-0 broadcast message with the signer's ephemeral key and + * wraps it into a SignatureShareRecord ready for `sendSignatureShareV2`. + */ +export async function getEddsaSignatureShareRound1( + userMsg1: MPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: 0 | 1 = 0, + otherSignerPartyId: 0 | 1 | 2 = 2 +): Promise { + const signedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey); + const share: EddsaMPCv2SignatureShareRound1Input = { + type: 'round1Input', + data: { msg1: signedMsg1 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} + +/** + * Verifies the peer's round-1 PGP signature and returns the raw deserialized + * message ready for `DSG.handleIncomingMessages`. + */ +export async function verifyBitGoEddsaMessageRound1( + parsedRound1Output: EddsaMPCv2SignatureShareRound1Output, + bitgoGpgKey: openpgp.Key, + peerPartyId: 0 | 1 | 2 = 2 +): Promise { + const rawBytes = await MPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, bitgoGpgKey); + return { + from: peerPartyId as MPCv2PartiesEnum, + payload: new Uint8Array(rawBytes), + }; +} + +/** + * Builds the round-2 signature share record. + */ +export async function getEddsaSignatureShareRound2( + userMsg2: MPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: 0 | 1 = 0, + otherSignerPartyId: 0 | 1 | 2 = 2 +): Promise { + const signedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey); + const share: EddsaMPCv2SignatureShareRound2Input = { + type: 'round2Input', + data: { msg2: signedMsg2 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} + +/** + * Verifies the peer's round-2 PGP signature and returns the raw deserialized + * message ready for `DSG.handleIncomingMessages`. + */ +export async function verifyBitGoEddsaMessageRound2( + parsedRound2Output: EddsaMPCv2SignatureShareRound2Output, + bitgoGpgKey: openpgp.Key, + peerPartyId: 0 | 1 | 2 = 2 +): Promise { + const rawBytes = await MPSComms.verifyMpsMessage(parsedRound2Output.data.msg2, bitgoGpgKey); + return { + from: peerPartyId as MPCv2PartiesEnum, + payload: new Uint8Array(rawBytes), + }; +} + +/** + * Builds the round-3 signature share record (final signer message). + * + * There is no corresponding `verifyBitGoEddsaMessageRound3` because Wallet Platform + * finalises the signing server-side after receiving round 3; the client obtains the + * signed transaction via `sendTxRequest`. + */ +export async function getEddsaSignatureShareRound3( + userMsg3: MPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: 0 | 1 = 0, + otherSignerPartyId: 0 | 1 | 2 = 2 +): Promise { + const signedMsg3 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg3.payload), userGpgPrivKey); + const share: EddsaMPCv2SignatureShareRound3Input = { + type: 'round3Input', + data: { msg3: signedMsg3 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index c327b9ae07..ac3aeda639 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -10,6 +10,8 @@ import { EcdsaUtils } from './bitgo/utils/tss/ecdsa/ecdsa'; export { EcdsaUtils }; import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2'; export { EcdsaMPCv2Utils }; +import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2'; +export { EddsaMPCv2Utils }; export { verifyEddsaTssWalletAddress, verifyMPCWalletAddress } from './bitgo/utils/tss/addressVerification'; export { GShare, SignShare, YShare } from './account-lib/mpc/tss/eddsa/types'; export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/tss/types'; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts new file mode 100644 index 0000000000..c830519ed2 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -0,0 +1,206 @@ +import * as assert from 'assert'; +import * as pgp from 'openpgp'; +import { EddsaMPSDsg, MPSComms, MPSUtil } from '@bitgo/sdk-lib-mpc'; +import { + EddsaMPCv2SignatureShareRound1Input, + EddsaMPCv2SignatureShareRound1Output, + EddsaMPCv2SignatureShareRound2Input, + EddsaMPCv2SignatureShareRound2Output, + EddsaMPCv2SignatureShareRound3Input, +} from '@bitgo/public-types'; +import { SignatureShareRecord, SignatureShareType } from '../../../../../../src'; +import { + getEddsaSignatureShareRound1, + getEddsaSignatureShareRound2, + getEddsaSignatureShareRound3, + verifyBitGoEddsaMessageRound1, + verifyBitGoEddsaMessageRound2, +} from '../../../../../../src/bitgo/tss/eddsa/eddsaMPCv2'; +import { decodeWithCodec } from '../../../../../../src/bitgo/utils/codecs'; +import { generateGPGKeyPair } from '../../../../../../src/bitgo/utils/opengpgUtils'; +import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; + +describe('EdDSA MPS DSG helper functions', async () => { + let userKeyShare: Buffer; + let bitgoKeyShare: Buffer; + let userGpgPrivKey: pgp.PrivateKey; + let bitgoGpgPrivKey: pgp.PrivateKey; + let bitgoGpgPubKey: pgp.Key; + + const signableHex = 'deadbeef'; + const derivationPath = 'm/0'; + + before('generate EdDSA DKG key shares', async () => { + const userGpgKeyPair = await generateGPGKeyPair('ed25519'); + const bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + + userGpgPrivKey = await pgp.readPrivateKey({ armoredKey: userGpgKeyPair.privateKey }); + bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); + bitgoGpgPubKey = await pgp.readKey({ armoredKey: bitgoGpgKeyPair.publicKey }); + + const [userDkg, , bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + userKeyShare = userDkg.getKeyShare(); + bitgoKeyShare = bitgoDkg.getKeyShare(); + }); + + // ── Round 1 ───────────────────────────────────────────────────────────────── + + it('getEddsaSignatureShareRound1 should build a valid round-1 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const share: SignatureShareRecord = await getEddsaSignatureShareRound1(userMsg1, userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound1Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound1Input' + ); + assert.strictEqual(parsed.type, 'round1Input'); + assert.ok(parsed.data.msg1.message, 'msg1.message should be set'); + assert.ok(parsed.data.msg1.signature, 'msg1.signature should be set'); + }); + + it('verifyBitGoEddsaMessageRound1 should verify a valid BitGo round-1 message', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const round1Output: EddsaMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { msg1: bitgoSignedMsg1 }, + }; + + const result = await verifyBitGoEddsaMessageRound1(round1Output, bitgoGpgPubKey); + + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.ok(result.payload.length > 0, 'payload should be non-empty'); + }); + + it('verifyBitGoEddsaMessageRound1 should throw on a tampered message', async () => { + const round1Output: EddsaMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { + msg1: { + message: Buffer.from('tampered').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', + }, + }, + }; + + await assert.rejects( + verifyBitGoEddsaMessageRound1(round1Output, bitgoGpgPubKey), + 'should throw on invalid signature' + ); + }); + + // ── Round 2 ───────────────────────────────────────────────────────────────── + + it('getEddsaSignatureShareRound2 should build a valid round-2 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg1 = await verifyBitGoEddsaMessageRound1( + { type: 'round1Output', data: { msg1: bitgoSignedMsg1 } }, + bitgoGpgPubKey + ); + const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]); + + const share: SignatureShareRecord = await getEddsaSignatureShareRound2(userMsg2, userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound2Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound2Input' + ); + assert.strictEqual(parsed.type, 'round2Input'); + assert.ok(parsed.data.msg2.message, 'msg2.message should be set'); + assert.ok(parsed.data.msg2.signature, 'msg2.signature should be set'); + }); + + it('verifyBitGoEddsaMessageRound2 should verify a valid BitGo round-2 message', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]); + const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); + + const round2Output: EddsaMPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { msg2: bitgoSignedMsg2 }, + }; + + void bitgoSignedMsg1; + const result = await verifyBitGoEddsaMessageRound2(round2Output, bitgoGpgPubKey); + + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.ok(result.payload.length > 0, 'payload should be non-empty'); + }); + + // ── Round 3 ───────────────────────────────────────────────────────────────── + + it('getEddsaSignatureShareRound3 should build a valid round-3 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + // Advance to round 2 + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg1 = await verifyBitGoEddsaMessageRound1( + { type: 'round1Output', data: { msg1: bitgoSignedMsg1 } }, + bitgoGpgPubKey + ); + const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]); + + const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]); + const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg2 = await verifyBitGoEddsaMessageRound2( + { type: 'round2Output', data: { msg2: bitgoSignedMsg2 } }, + bitgoGpgPubKey + ); + const [userMsg3] = userDsg.handleIncomingMessages([userMsg2, bitgoDeserializedMsg2]); + + const share: SignatureShareRecord = await getEddsaSignatureShareRound3(userMsg3, userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound3Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound3Input' + ); + assert.strictEqual(parsed.type, 'round3Input'); + assert.ok(parsed.data.msg3.message, 'msg3.message should be set'); + assert.ok(parsed.data.msg3.signature, 'msg3.signature should be set'); + }); +}); diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts index 05b68a979c..1646c2047e 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts @@ -1,3 +1,7 @@ +import crypto from 'crypto'; +import { x25519 } from '@noble/curves/ed25519'; +import { DKG } from './dkg'; + /** * Concatenates multiple Uint8Array instances into a single Uint8Array * @param chunks - Array of Uint8Array instances to concatenate @@ -7,3 +11,51 @@ export function concatBytes(chunks: Uint8Array[]): Uint8Array { const buffers = chunks.map((chunk) => Buffer.from(chunk)); return new Uint8Array(Buffer.concat(buffers)); } + +function generateX25519Keypair(seed?: Buffer): { privKey: Buffer; pubKey: Buffer } { + const privKey = seed ? seed.subarray(0, 32) : crypto.randomBytes(32); + const pubKey = Buffer.from(x25519.getPublicKey(privKey)); + return { privKey: Buffer.from(privKey), pubKey }; +} + +/** + * Generates EdDSA MPS DKG key shares for all 3 parties (user, backup, BitGo). + * Optional seeds produce deterministic output; omit for random key material. + * + * Mirrors `DklsUtils.generateDKGKeyShares` for ECDSA DKLS. + */ +export async function generateEdDsaDKGKeyShares( + seedUser?: Buffer, + seedBackup?: Buffer, + seedBitgo?: Buffer +): Promise<[DKG, DKG, DKG]> { + const user = new DKG(3, 2, 0); + const backup = new DKG(3, 2, 1); + const bitgo = new DKG(3, 2, 2); + + const userKP = generateX25519Keypair(seedUser); + const backupKP = generateX25519Keypair(seedBackup); + const bitgoKP = generateX25519Keypair(seedBitgo); + + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + const r1Messages = [ + user.getFirstMessage(seedUser), + backup.getFirstMessage(seedBackup), + bitgo.getFirstMessage(seedBitgo), + ]; + + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + + user.handleIncomingMessages(r2Messages); + backup.handleIncomingMessages(r2Messages); + bitgo.handleIncomingMessages(r2Messages); + + return [user, backup, bitgo]; +}