Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/tss/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
126 changes: 126 additions & 0 deletions modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts
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(
Copy link
Copy Markdown
Contributor

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:30getSignatureShareRoundOne/Two/Three (English numerals, no Ecdsa prefix). Here we have getEddsaSignatureShareRound1/2/3 (digits + redundant prefix since the file already lives under tss/eddsa/). Same for verifyBitGoEddsaMessageRound1/2 vs ECDSA's verifyBitGoMessagesAndSignaturesRoundOne/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.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: parameter is named bitgoGpgKey but the function accepts any peerPartyId: 0 | 1 | 2 — when called with USER or BACKUP as the peer, the name lies. peerGpgKey would match the contract. Same comment applies to verifyBitGoEddsaMessageRound2 at L93. Follow-up safe.

peerPartyId: 0 | 1 | 2 = 2
): Promise<MPSTypes.DeserializedMessage> {
const rawBytes = await MPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, bitgoGpgKey);
return {
from: peerPartyId as MPCv2PartiesEnum,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: peerPartyId as MPCv2PartiesEnum is an unsafe cast that's only needed because the parameter type is the literal union 0 | 1 | 2. Typing the parameter as MPCv2PartiesEnum directly removes the cast and makes the contract clearer at the call site. Follow-up safe.

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),
};
}
2 changes: 2 additions & 0 deletions modules/sdk-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
206 changes: 206 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: looks like leftover dead code from a refactor — bitgoSignedMsg1 is computed above and then explicitly discarded with void. Safe to remove both the assignment and this line. Follow-up safe.

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');
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage gaps worth filing as follow-ups (none blocking):

  • Round-2 verify has no tampered-signature negative test (round 1 has one at L94-108)
  • BACKUP path (partyId=1) of getEddsaSignatureShareRound{1,2,3} is never exercised
  • Deterministic-seed branch of generateEdDsaDKGKeyShares is untested — the test calls it without seeds, so that code path isn't verified
  • No direct assertion that all 3 DKG parties agree on the public key (currently only used as setup)

Loading
Loading