Skip to content

Commit 06cf112

Browse files
Marzooqacursoragent
andcommitted
feat(sdk-lib-mpc): add eddsa retrofit data type and dkg retrofit constructor
Add optional `retrofitData` parameter to the `DKG` constructor so parties can seed a retrofit DKG ceremony from their existing MPCv1 scalar instead of generating fresh key material. - Import and store `EddsaRetrofitData` on the `DKG` class instance. - Constructor gains a 4th optional param: `retrofitData?: EddsaRetrofitData`. - `getFirstMessage` branches on `this.retrofitData`: when set it calls `wasm.ed25519_dkg_round0_import` (ships in WCI-1217) passing the party's clamped scalar, aggregate public key, and chain code; otherwise it falls through to the existing `ed25519_dkg_round0_process` path. - Clear `retrofitData` once consumed, and persist/restore it in the session blob so a restored party can still run round 0. - Export `EddsaRetrofitData` as a named type from `eddsa-mps/index.ts` so callers can import it directly without going through `MPSTypes`. Tests derive retrofit inputs via `buildRetrofitData`, mirroring `Eddsa.keyShare` + `keyCombine`, and cover routing, determinism, and session export/restore. Ticket: WCI-1261 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fd9c409 commit 06cf112

6 files changed

Lines changed: 248 additions & 15 deletions

File tree

modules/sdk-lib-mpc/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
]
3737
},
3838
"dependencies": {
39-
"@bitgo/wasm-mps": "1.11.0",
39+
"@bitgo/wasm-mps": "1.12.0",
4040
"@noble/curves": "1.8.1",
4141
"@silencelaboratories/dkls-wasm-ll-node": "1.2.0-pre.4",
4242
"@silencelaboratories/dkls-wasm-ll-web": "1.2.0-pre.4",

modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { MsgState, Share } from '@bitgo/wasm-mps';
22
import { encode } from 'cbor-x';
33
import crypto from 'crypto';
4-
import { DeserializedMessage, DeserializedMessages, DkgState, EddsaReducedKeyShare } from './types';
4+
import { DeserializedMessage, DeserializedMessages, DkgState, EddsaReducedKeyShare, EddsaRetrofitData } from './types';
55

66
type NodeWasmer = typeof import('@bitgo/wasm-mps');
77
type WebWasmer = typeof import('@bitgo/wasm-mps/web');
@@ -44,13 +44,16 @@ export class DKG {
4444
private shareChaincode: Buffer | null = null;
4545
/** Lazily loaded WASM module */
4646
private wasmMps: WasmMps | null = null;
47+
/** Optional MPCv1 retrofit data; when set, round0 uses ed25519_dkg_round0_import */
48+
private retrofitData: EddsaRetrofitData | undefined;
4749

4850
protected dkgState: DkgState = DkgState.Uninitialized;
4951

50-
constructor(n: number, t: number, partyIdx: number) {
52+
constructor(n: number, t: number, partyIdx: number, retrofitData?: EddsaRetrofitData) {
5153
this.n = n;
5254
this.t = t;
5355
this.partyIdx = partyIdx;
56+
this.retrofitData = retrofitData;
5457
}
5558

5659
private async loadWasmMps(): Promise<void> {
@@ -124,13 +127,26 @@ export class DKG {
124127
const wasm = this.getWasmMps();
125128
let result: MsgState;
126129
try {
127-
result = wasm.ed25519_dkg_round0_process(this.partyIdx, this.decryptionKey!, this.otherPubKeys!, seed);
130+
if (this.retrofitData) {
131+
result = wasm.ed25519_dkg_round0_import(
132+
this.partyIdx,
133+
this.decryptionKey!,
134+
this.otherPubKeys!,
135+
Buffer.from(this.retrofitData.s_i_0, 'hex'),
136+
Buffer.from(this.retrofitData.expectedPk, 'hex'),
137+
Buffer.from(this.retrofitData.chainCode, 'hex')
138+
);
139+
} else {
140+
result = wasm.ed25519_dkg_round0_process(this.partyIdx, this.decryptionKey!, this.otherPubKeys!, seed);
141+
}
128142
} catch (err) {
129143
throw new Error(`Error while creating the first message from party ${this.partyIdx}: ${err}`);
130144
}
131145

132146
this.dkgStateBytes = Buffer.from(result.state);
133147
this.dkgState = DkgState.WaitMsg1;
148+
// Clear retrofit key material once consumed — it is not needed after round 0
149+
this.retrofitData = undefined;
134150
return { payload: new Uint8Array(result.msg), from: this.partyIdx };
135151
}
136152

@@ -267,6 +283,7 @@ export class DKG {
267283
dkgRound: this.dkgState,
268284
decryptionKey: this.decryptionKey?.toString('base64') ?? null,
269285
otherPubKeys: this.otherPubKeys?.map((k) => k.toString('base64')) ?? null,
286+
retrofitData: this.retrofitData,
270287
});
271288
}
272289

@@ -280,5 +297,6 @@ export class DKG {
280297
this.dkgState = data.dkgRound;
281298
this.decryptionKey = data.decryptionKey ? Buffer.from(data.decryptionKey, 'base64') : null;
282299
this.otherPubKeys = data.otherPubKeys ? (data.otherPubKeys as string[]).map((k) => Buffer.from(k, 'base64')) : null;
300+
this.retrofitData = data.retrofitData ?? undefined;
283301
}
284302
}

modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * as EddsaMPSDsg from './dsg';
33
export * as MPSUtil from './util';
44
export * as MPSTypes from './types';
55
export * as MPSComms from './commsLayer';
6+
export type { EddsaRetrofitData } from './types';

modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from 'assert';
33
import { x25519 } from '@noble/curves/ed25519';
44
import { DKG } from './dkg';
55
import { DSG } from './dsg';
6-
import { DeserializedMessages } from './types';
6+
import { DeserializedMessages, EddsaRetrofitData } from './types';
77

88
/**
99
* Concatenates multiple Uint8Array instances into a single Uint8Array
@@ -40,15 +40,18 @@ function validateSeed(seed?: EdDsaDKGPartySeed): EdDsaDKGPartySeed {
4040
export async function generateEdDsaDKGKeyShares(
4141
seedUser?: EdDsaDKGPartySeed,
4242
seedBackup?: EdDsaDKGPartySeed,
43-
seedBitgo?: EdDsaDKGPartySeed
43+
seedBitgo?: EdDsaDKGPartySeed,
44+
retrofitUser?: EddsaRetrofitData,
45+
retrofitBackup?: EddsaRetrofitData,
46+
retrofitBitgo?: EddsaRetrofitData
4447
): Promise<[DKG, DKG, DKG]> {
4548
const { encKey: userEncKey, dkgSeed: userDkgSeed } = validateSeed(seedUser);
4649
const { encKey: backupEncKey, dkgSeed: backupDkgSeed } = validateSeed(seedBackup);
4750
const { encKey: bitgoEncKey, dkgSeed: bitgoDkgSeed } = validateSeed(seedBitgo);
4851

49-
const user = new DKG(3, 2, 0);
50-
const backup = new DKG(3, 2, 1);
51-
const bitgo = new DKG(3, 2, 2);
52+
const user = new DKG(3, 2, 0, retrofitUser);
53+
const backup = new DKG(3, 2, 1, retrofitBackup);
54+
const bitgo = new DKG(3, 2, 2, retrofitBitgo);
5255

5356
const userKP = generateX25519Keypair(userEncKey);
5457
const backupKP = generateX25519Keypair(backupEncKey);

modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import assert from 'assert';
2-
import crypto from 'crypto';
2+
import crypto, { createHash } from 'crypto';
33
import { x25519 } from '@noble/curves/ed25519';
4-
import { EddsaMPSDkg, MPSTypes } from '../../../../src/tss/eddsa-mps';
4+
import { EddsaMPSDkg, MPSTypes, type EddsaRetrofitData } from '../../../../src/tss/eddsa-mps';
55
import { generateEdDsaDKGKeyShares } from './util';
6+
import { Ed25519Curve } from '../../../../src/curves/ed25519';
7+
import { Shamir } from '../../../../src/shamir/shamir';
8+
import {
9+
bigIntFromBufferLE,
10+
bigIntToBufferLE,
11+
bigIntFromBufferBE,
12+
bigIntToBufferBE,
13+
clamp,
14+
} from '../../../../src/util';
615

716
function makeKeypair(seed?: Buffer) {
817
const privKey = seed ? Buffer.from(seed.subarray(0, 32)) : crypto.randomBytes(32);
@@ -311,4 +320,206 @@ describe('EdDSA MPS DKG', function () {
311320
}, /DKG session is complete. Exporting the session is not allowed./);
312321
});
313322
});
323+
324+
describe('Retrofit DKG (ed25519_dkg_round0_import)', function () {
325+
const curve = new Ed25519Curve();
326+
const shamir = new Shamir(curve);
327+
// 2^256 — same base used by the Eddsa class for chaincode arithmetic
328+
const base = BigInt('0x010000000000000000000000000000000000000000000000000000000000000000');
329+
330+
/**
331+
* Mirrors Eddsa.keyShare(index, 2, 3) + Eddsa.keyCombine() from sdk-core.
332+
* Returns per-party EddsaRetrofitData with:
333+
* s_i_0 = pShare.u (combined clamped scalar, distinct per party)
334+
* expectedPk = pShare.y (aggregate Ed25519 public key, same across all parties)
335+
* chainCode = pShare.chaincode (combined 32-byte chain code, same across all parties)
336+
*/
337+
function buildRetrofitData(seeds: Buffer[]): EddsaRetrofitData[] {
338+
// Step 1: keyShare — derive per-party (u, y, chaincode, split_u)
339+
type PartyRaw = { u: bigint; y: bigint; chaincode: bigint; splitU: Record<number, bigint> };
340+
const n = seeds.length;
341+
const parties: PartyRaw[] = seeds.map((seed) => {
342+
const h = createHash('sha512').update(seed.subarray(0, 32)).digest();
343+
const u = clamp(bigIntFromBufferLE(h.subarray(0, 32) as Buffer));
344+
const y = curve.basePointMult(u);
345+
const chaincode = bigIntFromBufferBE(seed.subarray(32, 64) as Buffer);
346+
const { shares: splitU } = shamir.split(u, 2, n);
347+
return { u, y, chaincode, splitU };
348+
});
349+
350+
// Step 2: keyCombine — aggregate y and chaincode; pick u_i for each party i
351+
const aggY = parties.map((p) => p.y).reduce((acc, y) => curve.pointAdd(acc, y));
352+
const aggChaincode = parties.map((p) => p.chaincode).reduce((acc, cc) => (acc + cc) % base);
353+
const expectedPk = bigIntToBufferLE(aggY, 32).toString('hex');
354+
// Eddsa.keyCombine stores pShare.chaincode as bigIntToBufferBE — match that encoding
355+
const chainCode = bigIntToBufferBE(aggChaincode, 32).toString('hex');
356+
357+
return parties.map((party) => ({
358+
s_i_0: bigIntToBufferLE(party.u, 32).toString('hex'),
359+
expectedPk,
360+
chainCode,
361+
}));
362+
}
363+
364+
// Deterministic per-party seeds: 64 bytes each (first 32 = key seed, last 32 = chaincode).
365+
// buildRetrofitData calls Ed25519Curve.basePointMult which requires libsodium to be
366+
// initialized — run it inside before() rather than at describe-scope.
367+
const seeds = [
368+
Buffer.from(
369+
'a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270' +
370+
'9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9',
371+
'hex'
372+
),
373+
Buffer.from(
374+
'33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe' +
375+
'b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381',
376+
'hex'
377+
),
378+
Buffer.from(
379+
'ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a' +
380+
'44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f',
381+
'hex'
382+
),
383+
];
384+
let retrofitUser: EddsaRetrofitData;
385+
let retrofitBackup: EddsaRetrofitData;
386+
let retrofitBitgo: EddsaRetrofitData;
387+
388+
before(function () {
389+
[retrofitUser, retrofitBackup, retrofitBitgo] = buildRetrofitData(seeds);
390+
});
391+
392+
it('each party has a distinct s_i_0 but shared expectedPk', function () {
393+
assert.notStrictEqual(retrofitUser.s_i_0, retrofitBackup.s_i_0, 'user and backup s_i_0 must differ');
394+
assert.notStrictEqual(retrofitBackup.s_i_0, retrofitBitgo.s_i_0, 'backup and bitgo s_i_0 must differ');
395+
assert.strictEqual(retrofitUser.expectedPk, retrofitBackup.expectedPk, 'all parties share expectedPk');
396+
assert.strictEqual(retrofitBackup.expectedPk, retrofitBitgo.expectedPk, 'all parties share expectedPk');
397+
});
398+
399+
it('should route getFirstMessage through ed25519_dkg_round0_import and all parties agree on public key', async function () {
400+
const [user, backup, bitgo] = await generateEdDsaDKGKeyShares(
401+
undefined,
402+
undefined,
403+
undefined,
404+
retrofitUser,
405+
retrofitBackup,
406+
retrofitBitgo
407+
);
408+
409+
const userPk = user.getSharePublicKey().toString('hex');
410+
const backupPk = backup.getSharePublicKey().toString('hex');
411+
const bitgoPk = bitgo.getSharePublicKey().toString('hex');
412+
413+
assert.strictEqual(userPk, backupPk, 'user and backup must agree on public key after retrofit DKG');
414+
assert.strictEqual(backupPk, bitgoPk, 'backup and bitgo must agree on public key after retrofit DKG');
415+
assert.strictEqual(userPk.length, 64, 'public key must be 32 bytes (64 hex chars)');
416+
});
417+
418+
it('retrofit DKG produces a different public key than a fresh DKG', async function () {
419+
const [retrofitParty] = await generateEdDsaDKGKeyShares(
420+
undefined,
421+
undefined,
422+
undefined,
423+
retrofitUser,
424+
retrofitBackup,
425+
retrofitBitgo
426+
);
427+
const [freshParty] = await generateEdDsaDKGKeyShares();
428+
429+
assert.notStrictEqual(
430+
retrofitParty.getSharePublicKey().toString('hex'),
431+
freshParty.getSharePublicKey().toString('hex'),
432+
'retrofit and fresh DKG should produce distinct public keys'
433+
);
434+
});
435+
436+
it('retrofit DKG is deterministic: same retrofitData produces same public key', async function () {
437+
const [run1] = await generateEdDsaDKGKeyShares(
438+
undefined,
439+
undefined,
440+
undefined,
441+
retrofitUser,
442+
retrofitBackup,
443+
retrofitBitgo
444+
);
445+
const [run2] = await generateEdDsaDKGKeyShares(
446+
undefined,
447+
undefined,
448+
undefined,
449+
retrofitUser,
450+
retrofitBackup,
451+
retrofitBitgo
452+
);
453+
454+
assert.strictEqual(
455+
run1.getSharePublicKey().toString('hex'),
456+
run2.getSharePublicKey().toString('hex'),
457+
'retrofit DKG must be deterministic: same inputs must produce same public key'
458+
);
459+
});
460+
461+
it('session export/restore: restored party completes full retrofit DKG and agrees on public key', async function () {
462+
const userKP = makeKeypair();
463+
const backupKP = makeKeypair();
464+
const bitgoKP = makeKeypair();
465+
466+
// --- Simulate party 0 persisting its session before round 0 ---
467+
const user = new EddsaMPSDkg.DKG(3, 2, 0, retrofitUser);
468+
await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]);
469+
470+
const session = user.getSession();
471+
const parsed = JSON.parse(session);
472+
assert.deepStrictEqual(parsed.retrofitData, retrofitUser, 'getSession must include retrofitData');
473+
474+
// Restore party 0 into a fresh instance.
475+
// initDkg loads the WASM module; restoreSession then overwrites state/keys from the blob.
476+
const restoredUser = new EddsaMPSDkg.DKG(3, 2, 0);
477+
await restoredUser.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]);
478+
restoredUser.restoreSession(session);
479+
480+
// --- Run parties 1 and 2 normally ---
481+
const backup = new EddsaMPSDkg.DKG(3, 2, 1, retrofitBackup);
482+
const bitgo = new EddsaMPSDkg.DKG(3, 2, 2, retrofitBitgo);
483+
await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]);
484+
await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]);
485+
486+
// --- Round 0 ---
487+
const r1Messages = [restoredUser.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()];
488+
489+
// --- Round 1 ---
490+
const r2Messages = [
491+
...restoredUser.handleIncomingMessages(r1Messages),
492+
...backup.handleIncomingMessages(r1Messages),
493+
...bitgo.handleIncomingMessages(r1Messages),
494+
];
495+
496+
// --- Round 2 (completes DKG) ---
497+
restoredUser.handleIncomingMessages(r2Messages);
498+
backup.handleIncomingMessages(r2Messages);
499+
bitgo.handleIncomingMessages(r2Messages);
500+
501+
// All three parties must agree on the same public key
502+
const userPk = restoredUser.getSharePublicKey().toString('hex');
503+
const backupPk = backup.getSharePublicKey().toString('hex');
504+
const bitgoPk = bitgo.getSharePublicKey().toString('hex');
505+
506+
assert.strictEqual(userPk, backupPk, 'restored user and backup must agree on public key');
507+
assert.strictEqual(backupPk, bitgoPk, 'backup and bitgo must agree on public key');
508+
509+
// The public key must match the one from a non-restored retrofit run with the same inputs
510+
const [refUser] = await generateEdDsaDKGKeyShares(
511+
undefined,
512+
undefined,
513+
undefined,
514+
retrofitUser,
515+
retrofitBackup,
516+
retrofitBitgo
517+
);
518+
assert.strictEqual(
519+
userPk,
520+
refUser.getSharePublicKey().toString('hex'),
521+
'restored session must produce same public key as non-restored retrofit run'
522+
);
523+
});
524+
});
314525
});

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,10 +1059,10 @@
10591059
resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz"
10601060
integrity sha512-KoXavJvyDHlEN+sWcigbgxYJtdFaU7gS0EkYQbNH4npVjNlzo6rL6gwjyWbyOy7oEs65DhpJ9vY5kRbE/bKiTQ==
10611061

1062-
"@bitgo/wasm-mps@1.11.0":
1063-
version "1.11.0"
1064-
resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.11.0.tgz#642f0a970f3545e6e4fa4b7df1920a7309952923"
1065-
integrity sha512-+RnpCdBpF41//duuvdeoreEzDMUANSB14H/wTRKOxLLOOPCA6WiXVKV4/20mGMvI1Gcx39xDdQM62M9a2kUwtA==
1062+
"@bitgo/wasm-mps@1.12.0":
1063+
version "1.12.0"
1064+
resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.12.0.tgz#03f9fc8eaa25d3dcb5af61915bba890759110c65"
1065+
integrity sha512-rude1gS5ml/I/qpkCoeBwvMbveNQp4cWxWzh3wUO4SsXebJMHVmGmWE27EsTBDEiaYI470q5H4aI/oyAfENOUg==
10661066

10671067
"@bitgo/wasm-solana@^2.6.0":
10681068
version "2.6.0"

0 commit comments

Comments
 (0)