Skip to content

Commit b9f3f2f

Browse files
committed
feat(sdk-core): add EdDSA MPCv2 DSG helpers and DKG key-share util
- Add getEddsaSignatureShareRound{1,2,3} and verifyBitGoEddsaMessageRound{1,2} helpers in sdk-core for building/verifying PGP-signed MPS broadcast messages - Parameterise partyId/otherSignerPartyId (defaults: user=0, bitgo=2) to support non-user signers without hardcoding - Wire eddsaMpcV2 type in sendSignatureShareV2 (common.ts) - Export helpers as EddsaMPCv2Utils from sdk-core public index - Add generateEdDsaDKGKeyShares to sdk-lib-mpc MPSUtil (mirrors DklsUtils for ECDSA) - Add unit tests covering all 5 helpers using io-ts decodeWithCodec for assertions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> TICKET: WCI-153
1 parent 20b1b66 commit b9f3f2f

5 files changed

Lines changed: 388 additions & 0 deletions

File tree

modules/sdk-core/src/bitgo/tss/common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export async function sendSignatureShareV2(
131131
let type = '';
132132
if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'ecdsa') {
133133
type = 'ecdsaMpcV2';
134+
} else if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'eddsa') {
135+
type = 'eddsaMpcV2';
134136
} else if (multisigTypeVersion === undefined && mpcAlgorithm === 'eddsa') {
135137
type = 'eddsaMpcV1';
136138
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import assert from 'assert';
2+
import * as openpgp from 'openpgp';
3+
import { MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc';
4+
import {
5+
EddsaMPCv2SignatureShareRound1Input,
6+
EddsaMPCv2SignatureShareRound1Output,
7+
EddsaMPCv2SignatureShareRound2Input,
8+
EddsaMPCv2SignatureShareRound2Output,
9+
EddsaMPCv2SignatureShareRound3Input,
10+
} from '@bitgo/public-types';
11+
import { SignatureShareRecord, SignatureShareType } from '../../utils/tss/baseTypes';
12+
import { MPCv2PartiesEnum } from '../../utils/tss/ecdsa/typesMPCv2';
13+
14+
function partyIdToSignatureShareType(partyId: 0 | 1 | 2): SignatureShareType {
15+
assert(partyId === 0 || partyId === 1 || partyId === 2, 'Invalid partyId for EdDSA MPCv2 signing');
16+
switch (partyId) {
17+
case 0:
18+
return SignatureShareType.USER;
19+
case 1:
20+
return SignatureShareType.BACKUP;
21+
case 2:
22+
return SignatureShareType.BITGO;
23+
}
24+
}
25+
26+
/**
27+
* Builds the round-1 signature share record.
28+
*
29+
* PGP-signs the WASM round-0 broadcast message with the signer's ephemeral key and
30+
* wraps it into a SignatureShareRecord ready for `sendSignatureShareV2`.
31+
*/
32+
export async function getEddsaSignatureShareRound1(
33+
userMsg1: MPSTypes.DeserializedMessage,
34+
userGpgPrivKey: openpgp.PrivateKey,
35+
partyId: 0 | 1 = 0,
36+
otherSignerPartyId: 0 | 1 | 2 = 2
37+
): Promise<SignatureShareRecord> {
38+
const signedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey);
39+
const share: EddsaMPCv2SignatureShareRound1Input = {
40+
type: 'round1Input',
41+
data: { msg1: signedMsg1 },
42+
};
43+
return {
44+
from: partyIdToSignatureShareType(partyId),
45+
to: partyIdToSignatureShareType(otherSignerPartyId),
46+
share: JSON.stringify(share),
47+
};
48+
}
49+
50+
/**
51+
* Verifies the peer's round-1 PGP signature and returns the raw deserialized
52+
* message ready for `DSG.handleIncomingMessages`.
53+
*/
54+
export async function verifyBitGoEddsaMessageRound1(
55+
parsedRound1Output: EddsaMPCv2SignatureShareRound1Output,
56+
bitgoGpgKey: openpgp.Key,
57+
peerPartyId: 0 | 1 | 2 = 2
58+
): Promise<MPSTypes.DeserializedMessage> {
59+
const rawBytes = await MPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, bitgoGpgKey);
60+
return {
61+
from: peerPartyId as MPCv2PartiesEnum,
62+
payload: new Uint8Array(rawBytes),
63+
};
64+
}
65+
66+
/**
67+
* Builds the round-2 signature share record.
68+
*/
69+
export async function getEddsaSignatureShareRound2(
70+
userMsg2: MPSTypes.DeserializedMessage,
71+
userGpgPrivKey: openpgp.PrivateKey,
72+
partyId: 0 | 1 = 0,
73+
otherSignerPartyId: 0 | 1 | 2 = 2
74+
): Promise<SignatureShareRecord> {
75+
const signedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey);
76+
const share: EddsaMPCv2SignatureShareRound2Input = {
77+
type: 'round2Input',
78+
data: { msg2: signedMsg2 },
79+
};
80+
return {
81+
from: partyIdToSignatureShareType(partyId),
82+
to: partyIdToSignatureShareType(otherSignerPartyId),
83+
share: JSON.stringify(share),
84+
};
85+
}
86+
87+
/**
88+
* Verifies the peer's round-2 PGP signature and returns the raw deserialized
89+
* message ready for `DSG.handleIncomingMessages`.
90+
*/
91+
export async function verifyBitGoEddsaMessageRound2(
92+
parsedRound2Output: EddsaMPCv2SignatureShareRound2Output,
93+
bitgoGpgKey: openpgp.Key,
94+
peerPartyId: 0 | 1 | 2 = 2
95+
): Promise<MPSTypes.DeserializedMessage> {
96+
const rawBytes = await MPSComms.verifyMpsMessage(parsedRound2Output.data.msg2, bitgoGpgKey);
97+
return {
98+
from: peerPartyId as MPCv2PartiesEnum,
99+
payload: new Uint8Array(rawBytes),
100+
};
101+
}
102+
103+
/**
104+
* Builds the round-3 signature share record (final signer message).
105+
*
106+
* There is no corresponding `verifyBitGoEddsaMessageRound3` because Wallet Platform
107+
* finalises the signing server-side after receiving round 3; the client obtains the
108+
* signed transaction via `sendTxRequest`.
109+
*/
110+
export async function getEddsaSignatureShareRound3(
111+
userMsg3: MPSTypes.DeserializedMessage,
112+
userGpgPrivKey: openpgp.PrivateKey,
113+
partyId: 0 | 1 = 0,
114+
otherSignerPartyId: 0 | 1 | 2 = 2
115+
): Promise<SignatureShareRecord> {
116+
const signedMsg3 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg3.payload), userGpgPrivKey);
117+
const share: EddsaMPCv2SignatureShareRound3Input = {
118+
type: 'round3Input',
119+
data: { msg3: signedMsg3 },
120+
};
121+
return {
122+
from: partyIdToSignatureShareType(partyId),
123+
to: partyIdToSignatureShareType(otherSignerPartyId),
124+
share: JSON.stringify(share),
125+
};
126+
}

modules/sdk-core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { EcdsaUtils } from './bitgo/utils/tss/ecdsa/ecdsa';
1010
export { EcdsaUtils };
1111
import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2';
1212
export { EcdsaMPCv2Utils };
13+
import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2';
14+
export { EddsaMPCv2Utils };
1315
export { verifyEddsaTssWalletAddress, verifyMPCWalletAddress } from './bitgo/utils/tss/addressVerification';
1416
export { GShare, SignShare, YShare } from './account-lib/mpc/tss/eddsa/types';
1517
export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/tss/types';
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import * as assert from 'assert';
2+
import * as pgp from 'openpgp';
3+
import { EddsaMPSDsg, MPSComms, MPSUtil } from '@bitgo/sdk-lib-mpc';
4+
import {
5+
EddsaMPCv2SignatureShareRound1Input,
6+
EddsaMPCv2SignatureShareRound1Output,
7+
EddsaMPCv2SignatureShareRound2Input,
8+
EddsaMPCv2SignatureShareRound2Output,
9+
EddsaMPCv2SignatureShareRound3Input,
10+
} from '@bitgo/public-types';
11+
import { SignatureShareRecord, SignatureShareType } from '../../../../../../src';
12+
import {
13+
getEddsaSignatureShareRound1,
14+
getEddsaSignatureShareRound2,
15+
getEddsaSignatureShareRound3,
16+
verifyBitGoEddsaMessageRound1,
17+
verifyBitGoEddsaMessageRound2,
18+
} from '../../../../../../src/bitgo/tss/eddsa/eddsaMPCv2';
19+
import { decodeWithCodec } from '../../../../../../src/bitgo/utils/codecs';
20+
import { generateGPGKeyPair } from '../../../../../../src/bitgo/utils/opengpgUtils';
21+
import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2';
22+
23+
describe('EdDSA MPS DSG helper functions', async () => {
24+
let userKeyShare: Buffer;
25+
let bitgoKeyShare: Buffer;
26+
let userGpgPrivKey: pgp.PrivateKey;
27+
let bitgoGpgPrivKey: pgp.PrivateKey;
28+
let bitgoGpgPubKey: pgp.Key;
29+
30+
const signableHex = 'deadbeef';
31+
const derivationPath = 'm/0';
32+
33+
before('generate EdDSA DKG key shares', async () => {
34+
const userGpgKeyPair = await generateGPGKeyPair('ed25519');
35+
const bitgoGpgKeyPair = await generateGPGKeyPair('ed25519');
36+
37+
userGpgPrivKey = await pgp.readPrivateKey({ armoredKey: userGpgKeyPair.privateKey });
38+
bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey });
39+
bitgoGpgPubKey = await pgp.readKey({ armoredKey: bitgoGpgKeyPair.publicKey });
40+
41+
const [userDkg, , bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
42+
userKeyShare = userDkg.getKeyShare();
43+
bitgoKeyShare = bitgoDkg.getKeyShare();
44+
});
45+
46+
// ── Round 1 ─────────────────────────────────────────────────────────────────
47+
48+
it('getEddsaSignatureShareRound1 should build a valid round-1 share', async () => {
49+
const messageBuffer = Buffer.from(signableHex, 'hex');
50+
const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
51+
userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO);
52+
const userMsg1 = userDsg.getFirstMessage();
53+
54+
const share: SignatureShareRecord = await getEddsaSignatureShareRound1(userMsg1, userGpgPrivKey);
55+
56+
assert.strictEqual(share.from, SignatureShareType.USER);
57+
assert.strictEqual(share.to, SignatureShareType.BITGO);
58+
59+
const parsed = decodeWithCodec(
60+
EddsaMPCv2SignatureShareRound1Input,
61+
JSON.parse(share.share),
62+
'EddsaMPCv2SignatureShareRound1Input'
63+
);
64+
assert.strictEqual(parsed.type, 'round1Input');
65+
assert.ok(parsed.data.msg1.message, 'msg1.message should be set');
66+
assert.ok(parsed.data.msg1.signature, 'msg1.signature should be set');
67+
});
68+
69+
it('verifyBitGoEddsaMessageRound1 should verify a valid BitGo round-1 message', async () => {
70+
const messageBuffer = Buffer.from(signableHex, 'hex');
71+
const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO);
72+
bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER);
73+
const bitgoMsg1 = bitgoDsg.getFirstMessage();
74+
75+
const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey);
76+
const round1Output: EddsaMPCv2SignatureShareRound1Output = {
77+
type: 'round1Output',
78+
data: { msg1: bitgoSignedMsg1 },
79+
};
80+
81+
const result = await verifyBitGoEddsaMessageRound1(round1Output, bitgoGpgPubKey);
82+
83+
assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO);
84+
assert.ok(result.payload.length > 0, 'payload should be non-empty');
85+
});
86+
87+
it('verifyBitGoEddsaMessageRound1 should throw on a tampered message', async () => {
88+
const round1Output: EddsaMPCv2SignatureShareRound1Output = {
89+
type: 'round1Output',
90+
data: {
91+
msg1: {
92+
message: Buffer.from('tampered').toString('base64'),
93+
signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n',
94+
},
95+
},
96+
};
97+
98+
await assert.rejects(
99+
verifyBitGoEddsaMessageRound1(round1Output, bitgoGpgPubKey),
100+
'should throw on invalid signature'
101+
);
102+
});
103+
104+
// ── Round 2 ─────────────────────────────────────────────────────────────────
105+
106+
it('getEddsaSignatureShareRound2 should build a valid round-2 share', async () => {
107+
const messageBuffer = Buffer.from(signableHex, 'hex');
108+
const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
109+
userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO);
110+
const userMsg1 = userDsg.getFirstMessage();
111+
112+
const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO);
113+
bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER);
114+
const bitgoMsg1 = bitgoDsg.getFirstMessage();
115+
116+
const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey);
117+
const bitgoDeserializedMsg1 = await verifyBitGoEddsaMessageRound1(
118+
{ type: 'round1Output', data: { msg1: bitgoSignedMsg1 } },
119+
bitgoGpgPubKey
120+
);
121+
const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]);
122+
123+
const share: SignatureShareRecord = await getEddsaSignatureShareRound2(userMsg2, userGpgPrivKey);
124+
125+
assert.strictEqual(share.from, SignatureShareType.USER);
126+
assert.strictEqual(share.to, SignatureShareType.BITGO);
127+
128+
const parsed = decodeWithCodec(
129+
EddsaMPCv2SignatureShareRound2Input,
130+
JSON.parse(share.share),
131+
'EddsaMPCv2SignatureShareRound2Input'
132+
);
133+
assert.strictEqual(parsed.type, 'round2Input');
134+
assert.ok(parsed.data.msg2.message, 'msg2.message should be set');
135+
assert.ok(parsed.data.msg2.signature, 'msg2.signature should be set');
136+
});
137+
138+
it('verifyBitGoEddsaMessageRound2 should verify a valid BitGo round-2 message', async () => {
139+
const messageBuffer = Buffer.from(signableHex, 'hex');
140+
const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
141+
userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO);
142+
const userMsg1 = userDsg.getFirstMessage();
143+
144+
const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO);
145+
bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER);
146+
const bitgoMsg1 = bitgoDsg.getFirstMessage();
147+
148+
const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey);
149+
const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]);
150+
const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey);
151+
152+
const round2Output: EddsaMPCv2SignatureShareRound2Output = {
153+
type: 'round2Output',
154+
data: { msg2: bitgoSignedMsg2 },
155+
};
156+
157+
void bitgoSignedMsg1;
158+
const result = await verifyBitGoEddsaMessageRound2(round2Output, bitgoGpgPubKey);
159+
160+
assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO);
161+
assert.ok(result.payload.length > 0, 'payload should be non-empty');
162+
});
163+
164+
// ── Round 3 ─────────────────────────────────────────────────────────────────
165+
166+
it('getEddsaSignatureShareRound3 should build a valid round-3 share', async () => {
167+
const messageBuffer = Buffer.from(signableHex, 'hex');
168+
const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
169+
userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO);
170+
const userMsg1 = userDsg.getFirstMessage();
171+
172+
const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO);
173+
bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER);
174+
const bitgoMsg1 = bitgoDsg.getFirstMessage();
175+
176+
// Advance to round 2
177+
const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey);
178+
const bitgoDeserializedMsg1 = await verifyBitGoEddsaMessageRound1(
179+
{ type: 'round1Output', data: { msg1: bitgoSignedMsg1 } },
180+
bitgoGpgPubKey
181+
);
182+
const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]);
183+
184+
const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]);
185+
const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey);
186+
const bitgoDeserializedMsg2 = await verifyBitGoEddsaMessageRound2(
187+
{ type: 'round2Output', data: { msg2: bitgoSignedMsg2 } },
188+
bitgoGpgPubKey
189+
);
190+
const [userMsg3] = userDsg.handleIncomingMessages([userMsg2, bitgoDeserializedMsg2]);
191+
192+
const share: SignatureShareRecord = await getEddsaSignatureShareRound3(userMsg3, userGpgPrivKey);
193+
194+
assert.strictEqual(share.from, SignatureShareType.USER);
195+
assert.strictEqual(share.to, SignatureShareType.BITGO);
196+
197+
const parsed = decodeWithCodec(
198+
EddsaMPCv2SignatureShareRound3Input,
199+
JSON.parse(share.share),
200+
'EddsaMPCv2SignatureShareRound3Input'
201+
);
202+
assert.strictEqual(parsed.type, 'round3Input');
203+
assert.ok(parsed.data.msg3.message, 'msg3.message should be set');
204+
assert.ok(parsed.data.msg3.signature, 'msg3.signature should be set');
205+
});
206+
});

0 commit comments

Comments
 (0)