-
Notifications
You must be signed in to change notification settings - Fork 302
feat(sdk-core): add EdDSA MPCv2 DSG helpers and DKG key-share util #8687
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SignatureShareRecord> { | ||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: parameter is named |
||
| peerPartyId: 0 | 1 | 2 = 2 | ||
| ): Promise<MPSTypes.DeserializedMessage> { | ||
| const rawBytes = await MPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, bitgoGpgKey); | ||
| return { | ||
| from: peerPartyId as MPCv2PartiesEnum, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
| 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<SignatureShareRecord> { | ||
| 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<MPSTypes.DeserializedMessage> { | ||
| 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<SignatureShareRecord> { | ||
| 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), | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: looks like leftover dead code from a refactor — |
||
| 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'); | ||
| }); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test coverage gaps worth filing as follow-ups (none blocking):
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Naming inconsistent with ECDSA equivalents. Compare
modules/sdk-core/src/bitgo/tss/ecdsa/ecdsaMPCv2.ts:30—getSignatureShareRoundOne/Two/Three(English numerals, noEcdsaprefix). Here we havegetEddsaSignatureShareRound1/2/3(digits + redundant prefix since the file already lives undertss/eddsa/). Same forverifyBitGoEddsaMessageRound1/2vs ECDSA'sverifyBitGoMessagesAndSignaturesRoundOne/Two.Follow-up-safe today (these aren't barrel-exported), but #8697 adds deep-import callers — easier to pick names now than to ripple a rename later. Suggest
getSignatureShareRoundOne/Two/Three+verifyBitGoMessageRoundOne/Two.