Skip to content

Commit a6d8642

Browse files
committed
feat(sdk-core): add EddsaMPCv2Utils.createKeychainsWithExternalSigner
Add createKeychainsWithExternalSigner to EddsaMPCv2Utils to orchestrate EdDSA MPCv2 DKG via callbacks and WP round-trips, following the same external-signer pattern as EcdsaMPCv2Utils (ecdsaMPCv2.ts:655-735). The method: 1. Resolves the BitGo EdDSA MPCv2 GPG key (eddsaMpcv2PublicKey, not the ECDSA mpcv2PublicKey) and guards against a missing key. 2. Calls initializeCallback to let the external signer generate GPG keys. 3. Calls round1Callback for both parties' DKG round-1 messages. 4. Sends round-1 messages to WP via sendKeyGenerationRound1. 5. Calls round2Callback with BitGo's round-1 reply. 6. Sends round-2 messages to WP via sendKeyGenerationRound2, which returns commonPublicKeychain. 7. Calls finalizeCallback with BitGo's round-2 reply and asserts that the keychain returned by the callback matches commonPublicKeychain. 8. Registers user, backup, and BitGo keychains with isMPCv2: true. Messages are MPSTypes.MPSSignedMessage passed directly — no formatBitgoBroadcastMessage / formatP2PMessage wrapping needed (EdDSA MPS uses GPG detached signatures instead of DKLs auth-enc). Tests cover: callback invocation order, argument threading between rounds, isMPCv2 keychain registration, mismatched commonKeychain rejection, session-ID mismatch rejection, and missing BitGo GPG key rejection. Ticket: WCI-916 Session-Id: 52cc0308-5664-4de7-adc0-e1ddeebb9011 Task-Id: 489a58ea-0bff-4b3a-bd2e-8d32cda52a7b
1 parent 1f22c84 commit a6d8642

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
MPCv2KeyGenStateEnum,
1313
MPCv2PartyFromStringOrNumber,
1414
} from '@bitgo/public-types';
15+
import { EddsaMPCv2KeyGenCallbacks } from '../../../wallet/iWallets';
1516
import { ed25519 } from '@noble/curves/ed25519';
1617
import { EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc';
1718
import { KeychainsTriplet } from '../../../baseCoin';
@@ -213,6 +214,95 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
213214
};
214215
}
215216

217+
async createKeychainsWithExternalSigner(params: {
218+
enterprise: string;
219+
callbacks: EddsaMPCv2KeyGenCallbacks;
220+
}): Promise<KeychainsTriplet> {
221+
const { enterprise, callbacks } = params;
222+
223+
const { eddsaMpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(enterprise, true);
224+
const bitgoPublicGpgKey = eddsaMpcv2PublicKey ?? this.bitgoEddsaMpcv2PublicGpgKey;
225+
assert(bitgoPublicGpgKey, 'Failed to get BitGo EdDSA MPCv2 GPG public key');
226+
const bitgoPublicGpgKeyArmored = bitgoPublicGpgKey.armor();
227+
228+
if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
229+
assert(isBitgoEddsaMpcv2PubKey(bitgoPublicGpgKeyArmored), 'Invalid BitGo EdDSA MPCv2 GPG public key');
230+
}
231+
232+
// Round 0: both parties generate GPG keys
233+
const { userGpgPublicKey, backupGpgPublicKey, userState, backupState } = await callbacks.initializeCallback({
234+
enterprise,
235+
bitgoPublicGpgKey: bitgoPublicGpgKeyArmored,
236+
});
237+
238+
assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required');
239+
assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required');
240+
241+
// Round 1: both parties run DKG round 1
242+
const {
243+
userSignedMsg1,
244+
backupSignedMsg1,
245+
userState: userStateAfter1,
246+
backupState: backupStateAfter1,
247+
} = await callbacks.round1Callback({
248+
bitgoPublicGpgKey: bitgoPublicGpgKeyArmored,
249+
userGpgPublicKey,
250+
backupGpgPublicKey,
251+
userState,
252+
backupState,
253+
});
254+
255+
const { sessionId, bitgoMsg1 } = await this.sendKeyGenerationRound1(enterprise, {
256+
userGpgPublicKey,
257+
backupGpgPublicKey,
258+
userMsg1: userSignedMsg1,
259+
backupMsg1: backupSignedMsg1,
260+
});
261+
262+
// Round 2: both parties run DKG round 2
263+
const {
264+
userSignedMsg2,
265+
backupSignedMsg2,
266+
userState: userStateAfter2,
267+
backupState: backupStateAfter2,
268+
} = await callbacks.round2Callback({
269+
bitgoMsg1,
270+
userSignedMsg1,
271+
backupSignedMsg1,
272+
userState: userStateAfter1,
273+
backupState: backupStateAfter1,
274+
});
275+
276+
const {
277+
sessionId: sessionIdRound2,
278+
commonPublicKeychain: bitgoCommonKeychain,
279+
bitgoMsg2,
280+
} = await this.sendKeyGenerationRound2(enterprise, {
281+
sessionId,
282+
userMsg2: userSignedMsg2,
283+
backupMsg2: backupSignedMsg2,
284+
});
285+
assert.strictEqual(sessionId, sessionIdRound2, 'Round 1 and round 2 session IDs do not match');
286+
287+
// Finalize: both parties verify and derive the common keychain
288+
const { commonKeychain } = await callbacks.finalizeCallback({
289+
bitgoMsg2,
290+
bitgoCommonKeychain,
291+
userState: userStateAfter2,
292+
backupState: backupStateAfter2,
293+
});
294+
assert.strictEqual(commonKeychain, bitgoCommonKeychain, 'Common keychains do not match');
295+
296+
const keychains = this.baseCoin.keychains();
297+
const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([
298+
keychains.add({ source: 'user', keyType: 'tss' as KeyType, commonKeychain, isMPCv2: true }),
299+
keychains.add({ source: 'backup', keyType: 'tss' as KeyType, commonKeychain, isMPCv2: true }),
300+
this.addBitgoKeychain(commonKeychain),
301+
]);
302+
303+
return { userKeychain, backupKeychain, bitgoKeychain };
304+
}
305+
216306
// #region keychain utils
217307
async createParticipantKeychain(
218308
participantIndex: MPCv2PartyFromStringOrNumber,

modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
CustomEddsaMPCv2SigningRound2GeneratingFunction,
2020
CustomEddsaMPCv2SigningRound3GeneratingFunction,
2121
EDDSAUtils,
22+
EddsaMPCv2KeyGenCallbacks,
2223
EddsaMPCv2Utils,
2324
IBaseCoin,
2425
IWallet,
@@ -1812,3 +1813,156 @@ describe('signRecoveryEddsaMPCv2', () => {
18121813
);
18131814
});
18141815
});
1816+
1817+
describe('EddsaMPCv2Utils.createKeychainsWithExternalSigner', function () {
1818+
let utils: EddsaMPCv2Utils;
1819+
let callbacks: EddsaMPCv2KeyGenCallbacks;
1820+
const enterprise = 'enterprise-id';
1821+
const sessionId = 'session-001';
1822+
const commonKeychain = 'common-keychain-hex';
1823+
1824+
const userState = { encryptedData: 'u-data', encryptedDataKey: 'u-data-key' };
1825+
const backupState = { encryptedData: 'b-data', encryptedDataKey: 'b-data-key' };
1826+
const userSignedMsg1: MPSTypes.MPSSignedMessage = { message: 'umsg1', signature: 'usig1' };
1827+
const backupSignedMsg1: MPSTypes.MPSSignedMessage = { message: 'bmsg1', signature: 'bsig1' };
1828+
const userSignedMsg2: MPSTypes.MPSSignedMessage = { message: 'umsg2', signature: 'usig2' };
1829+
const backupSignedMsg2: MPSTypes.MPSSignedMessage = { message: 'bmsg2', signature: 'bsig2' };
1830+
const bitgoMsg1: MPSTypes.MPSSignedMessage = { message: 'bitgo-msg1', signature: 'bitgo-sig1' };
1831+
const bitgoMsg2: MPSTypes.MPSSignedMessage = { message: 'bitgo-msg2', signature: 'bitgo-sig2' };
1832+
1833+
beforeEach(function () {
1834+
callbacks = {
1835+
initializeCallback: sinon.stub().resolves({
1836+
userGpgPublicKey: 'user-gpg-pub',
1837+
backupGpgPublicKey: 'backup-gpg-pub',
1838+
userState,
1839+
backupState,
1840+
}),
1841+
round1Callback: sinon.stub().resolves({
1842+
userSignedMsg1,
1843+
backupSignedMsg1,
1844+
userState,
1845+
backupState,
1846+
}),
1847+
round2Callback: sinon.stub().resolves({
1848+
userSignedMsg2,
1849+
backupSignedMsg2,
1850+
userState,
1851+
backupState,
1852+
}),
1853+
finalizeCallback: sinon.stub().resolves({ commonKeychain }),
1854+
};
1855+
1856+
const mockBitGo = {
1857+
getEnv: sinon.stub().returns('dev'),
1858+
} as any;
1859+
1860+
const mockKeychains = {
1861+
add: sinon.stub().callsFake((params: any) =>
1862+
Promise.resolve({ id: `${params.source}-key-id`, commonKeychain, isMPCv2: true })
1863+
),
1864+
};
1865+
1866+
const mockCoin = {
1867+
keychains: sinon.stub().returns(mockKeychains),
1868+
} as any;
1869+
1870+
utils = new EddsaMPCv2Utils(mockBitGo, mockCoin);
1871+
1872+
sinon.stub(utils, 'getBitgoGpgPubkeyBasedOnFeatureFlags' as any).resolves({ eddsaMpcv2PublicKey: null });
1873+
(utils as any).bitgoEddsaMpcv2PublicGpgKey = { armor: () => '-----BEGIN PGP PUBLIC KEY BLOCK-----\n' };
1874+
1875+
sinon.stub(utils, 'sendKeyGenerationRound1').resolves({
1876+
sessionId: sessionId as any,
1877+
bitgoMsg1,
1878+
});
1879+
sinon.stub(utils, 'sendKeyGenerationRound2').resolves({
1880+
sessionId: sessionId as any,
1881+
commonPublicKeychain: commonKeychain as any,
1882+
bitgoMsg2,
1883+
});
1884+
sinon.stub(utils as any, 'addBitgoKeychain').resolves({ id: 'bitgo-key-id', commonKeychain, isMPCv2: true });
1885+
});
1886+
1887+
afterEach(function () {
1888+
sinon.restore();
1889+
});
1890+
1891+
it('should invoke callbacks in order and return keychains', async function () {
1892+
const keychains = await utils.createKeychainsWithExternalSigner({ enterprise, callbacks });
1893+
1894+
assert.ok(keychains.userKeychain);
1895+
assert.ok(keychains.backupKeychain);
1896+
assert.ok(keychains.bitgoKeychain);
1897+
1898+
sinon.assert.calledOnce(callbacks.initializeCallback as sinon.SinonStub);
1899+
sinon.assert.calledOnce(callbacks.round1Callback as sinon.SinonStub);
1900+
sinon.assert.calledOnce(callbacks.round2Callback as sinon.SinonStub);
1901+
sinon.assert.calledOnce(callbacks.finalizeCallback as sinon.SinonStub);
1902+
});
1903+
1904+
it('should pass round1 response to round2Callback', async function () {
1905+
await utils.createKeychainsWithExternalSigner({ enterprise, callbacks });
1906+
1907+
const round2Args = (callbacks.round2Callback as sinon.SinonStub).firstCall.args[0];
1908+
assert.deepStrictEqual(round2Args.bitgoMsg1, bitgoMsg1);
1909+
assert.deepStrictEqual(round2Args.userSignedMsg1, userSignedMsg1);
1910+
assert.deepStrictEqual(round2Args.backupSignedMsg1, backupSignedMsg1);
1911+
assert.deepStrictEqual(round2Args.userState, userState);
1912+
assert.deepStrictEqual(round2Args.backupState, backupState);
1913+
});
1914+
1915+
it('should pass round2 response to finalizeCallback', async function () {
1916+
await utils.createKeychainsWithExternalSigner({ enterprise, callbacks });
1917+
1918+
const finalizeArgs = (callbacks.finalizeCallback as sinon.SinonStub).firstCall.args[0];
1919+
assert.deepStrictEqual(finalizeArgs.bitgoMsg2, bitgoMsg2);
1920+
assert.strictEqual(finalizeArgs.bitgoCommonKeychain, commonKeychain);
1921+
assert.deepStrictEqual(finalizeArgs.userState, userState);
1922+
assert.deepStrictEqual(finalizeArgs.backupState, backupState);
1923+
});
1924+
1925+
it('should register keychains with isMPCv2: true', async function () {
1926+
await utils.createKeychainsWithExternalSigner({ enterprise, callbacks });
1927+
1928+
const mockKeychains = (utils as any).baseCoin.keychains();
1929+
const addCalls = (mockKeychains.add as sinon.SinonStub).getCalls();
1930+
assert.ok(addCalls.length >= 2, 'keychains.add should be called for user and backup');
1931+
for (const call of addCalls) {
1932+
assert.strictEqual(call.args[0].isMPCv2, true);
1933+
assert.strictEqual(call.args[0].keyType, 'tss');
1934+
assert.strictEqual(call.args[0].commonKeychain, commonKeychain);
1935+
}
1936+
});
1937+
1938+
it('should reject when finalizeCallback returns mismatched commonKeychain', async function () {
1939+
(callbacks.finalizeCallback as sinon.SinonStub).resolves({ commonKeychain: 'different-keychain' });
1940+
1941+
await assert.rejects(
1942+
() => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }),
1943+
/Common keychains do not match/
1944+
);
1945+
});
1946+
1947+
it('should reject when round2 returns mismatched sessionId', async function () {
1948+
(utils.sendKeyGenerationRound2 as sinon.SinonStub).resolves({
1949+
sessionId: 'different-session' as any,
1950+
commonPublicKeychain: commonKeychain as any,
1951+
bitgoMsg2,
1952+
});
1953+
1954+
await assert.rejects(
1955+
() => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }),
1956+
/Round 1 and round 2 session IDs do not match/
1957+
);
1958+
});
1959+
1960+
it('should reject when BitGo EdDSA MPCv2 GPG public key is missing', async function () {
1961+
(utils as any).bitgoEddsaMpcv2PublicGpgKey = undefined;
1962+
1963+
await assert.rejects(
1964+
() => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }),
1965+
/Failed to get BitGo EdDSA MPCv2 GPG public key/
1966+
);
1967+
});
1968+
});

0 commit comments

Comments
 (0)