From 6117b6d19a19f205119e1af7925efc5902a98ce0 Mon Sep 17 00:00:00 2001 From: BitGo Agent Date: Wed, 12 Aug 2026 07:01:13 +0000 Subject: [PATCH] feat(sdk-lib-mpc): eddsaRetrofitData type + DKG retrofit constructor + getFirstMessage routing 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 passing the party's clamped scalar, aggregate public key, and chain code; otherwise falls through to the existing ed25519_dkg_round0_process path. - Export EddsaRetrofitData as a named type from eddsa-mps/index.ts. - Bump @bitgo/wasm-mps 1.11.0 -> 1.12.0 which exports ed25519_dkg_round0_import with a proper TypeScript signature. - getSession/restoreSession now round-trip retrofitData so a persisted session correctly resumes the retrofit path. - retrofitData is cleared after getFirstMessage consumes it to bound the lifetime of the private scalar. - generateEdDsaDKGKeyShares in util.ts accepts per-party retrofitData params forwarded to the DKG constructor. - Add retrofit DKG tests: routing, determinism, session persistence, and full end-to-end restore round-trip. Ticket: WCI-1261 --- modules/sdk-lib-mpc/package.json | 2 +- modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts | 24 +- .../sdk-lib-mpc/src/tss/eddsa-mps/index.ts | 1 + modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts | 13 +- .../sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts | 216 +++++++++++++++++- yarn.lock | 8 +- 6 files changed, 249 insertions(+), 15 deletions(-) diff --git a/modules/sdk-lib-mpc/package.json b/modules/sdk-lib-mpc/package.json index ee55368071..67e568833c 100644 --- a/modules/sdk-lib-mpc/package.json +++ b/modules/sdk-lib-mpc/package.json @@ -36,7 +36,7 @@ ] }, "dependencies": { - "@bitgo/wasm-mps": "1.11.0", + "@bitgo/wasm-mps": "1.12.0", "@noble/curves": "1.8.1", "@silencelaboratories/dkls-wasm-ll-node": "1.2.0-pre.4", "@silencelaboratories/dkls-wasm-ll-web": "1.2.0-pre.4", diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts index ff16e01be0..4083a606d4 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts @@ -1,7 +1,7 @@ import type { MsgState, Share } from '@bitgo/wasm-mps'; import { encode } from 'cbor-x'; import crypto from 'crypto'; -import { DeserializedMessage, DeserializedMessages, DkgState, EddsaReducedKeyShare } from './types'; +import { DeserializedMessage, DeserializedMessages, DkgState, EddsaReducedKeyShare, EddsaRetrofitData } from './types'; type NodeWasmer = typeof import('@bitgo/wasm-mps'); type WebWasmer = typeof import('@bitgo/wasm-mps/web'); @@ -44,13 +44,16 @@ export class DKG { private shareChaincode: Buffer | null = null; /** Lazily loaded WASM module */ private wasmMps: WasmMps | null = null; + /** Optional MPCv1 retrofit data; when set, round0 uses ed25519_dkg_round0_import */ + private retrofitData: EddsaRetrofitData | undefined; protected dkgState: DkgState = DkgState.Uninitialized; - constructor(n: number, t: number, partyIdx: number) { + constructor(n: number, t: number, partyIdx: number, retrofitData?: EddsaRetrofitData) { this.n = n; this.t = t; this.partyIdx = partyIdx; + this.retrofitData = retrofitData; } private async loadWasmMps(): Promise { @@ -124,13 +127,26 @@ export class DKG { const wasm = this.getWasmMps(); let result: MsgState; try { - result = wasm.ed25519_dkg_round0_process(this.partyIdx, this.decryptionKey!, this.otherPubKeys!, seed); + if (this.retrofitData) { + result = wasm.ed25519_dkg_round0_import( + this.partyIdx, + this.decryptionKey!, + this.otherPubKeys!, + Buffer.from(this.retrofitData.s_i_0, 'hex'), + Buffer.from(this.retrofitData.expectedPk, 'hex'), + Buffer.from(this.retrofitData.chainCode, 'hex') + ); + } else { + result = wasm.ed25519_dkg_round0_process(this.partyIdx, this.decryptionKey!, this.otherPubKeys!, seed); + } } catch (err) { throw new Error(`Error while creating the first message from party ${this.partyIdx}: ${err}`); } this.dkgStateBytes = Buffer.from(result.state); this.dkgState = DkgState.WaitMsg1; + // Clear retrofit key material once consumed — it is not needed after round 0 + this.retrofitData = undefined; return { payload: new Uint8Array(result.msg), from: this.partyIdx }; } @@ -267,6 +283,7 @@ export class DKG { dkgRound: this.dkgState, decryptionKey: this.decryptionKey?.toString('base64') ?? null, otherPubKeys: this.otherPubKeys?.map((k) => k.toString('base64')) ?? null, + retrofitData: this.retrofitData, }); } @@ -280,5 +297,6 @@ export class DKG { this.dkgState = data.dkgRound; this.decryptionKey = data.decryptionKey ? Buffer.from(data.decryptionKey, 'base64') : null; this.otherPubKeys = data.otherPubKeys ? (data.otherPubKeys as string[]).map((k) => Buffer.from(k, 'base64')) : null; + this.retrofitData = data.retrofitData ?? undefined; } } diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts index cc355458a4..33aa426190 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts @@ -3,3 +3,4 @@ export * as EddsaMPSDsg from './dsg'; export * as MPSUtil from './util'; export * as MPSTypes from './types'; export * as MPSComms from './commsLayer'; +export type { EddsaRetrofitData } from './types'; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts index 63aaf65a53..4c2002efd6 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts @@ -3,7 +3,7 @@ import assert from 'assert'; import { x25519 } from '@noble/curves/ed25519'; import { DKG } from './dkg'; import { DSG } from './dsg'; -import { DeserializedMessages } from './types'; +import { DeserializedMessages, EddsaRetrofitData } from './types'; /** * Concatenates multiple Uint8Array instances into a single Uint8Array @@ -40,15 +40,18 @@ function validateSeed(seed?: EdDsaDKGPartySeed): EdDsaDKGPartySeed { export async function generateEdDsaDKGKeyShares( seedUser?: EdDsaDKGPartySeed, seedBackup?: EdDsaDKGPartySeed, - seedBitgo?: EdDsaDKGPartySeed + seedBitgo?: EdDsaDKGPartySeed, + retrofitUser?: EddsaRetrofitData, + retrofitBackup?: EddsaRetrofitData, + retrofitBitgo?: EddsaRetrofitData ): Promise<[DKG, DKG, DKG]> { const { encKey: userEncKey, dkgSeed: userDkgSeed } = validateSeed(seedUser); const { encKey: backupEncKey, dkgSeed: backupDkgSeed } = validateSeed(seedBackup); const { encKey: bitgoEncKey, dkgSeed: bitgoDkgSeed } = validateSeed(seedBitgo); - const user = new DKG(3, 2, 0); - const backup = new DKG(3, 2, 1); - const bitgo = new DKG(3, 2, 2); + const user = new DKG(3, 2, 0, retrofitUser); + const backup = new DKG(3, 2, 1, retrofitBackup); + const bitgo = new DKG(3, 2, 2, retrofitBitgo); const userKP = generateX25519Keypair(userEncKey); const backupKP = generateX25519Keypair(backupEncKey); diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts index f826536fec..d32817abcc 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts @@ -1,8 +1,17 @@ import assert from 'assert'; -import crypto from 'crypto'; +import crypto, { createHash } from 'crypto'; import { x25519 } from '@noble/curves/ed25519'; -import { EddsaMPSDkg, MPSTypes } from '../../../../src/tss/eddsa-mps'; +import { EddsaMPSDkg, MPSTypes, type EddsaRetrofitData } from '../../../../src/tss/eddsa-mps'; import { generateEdDsaDKGKeyShares } from './util'; +import { Ed25519Curve } from '../../../../src/curves/ed25519'; +import { Shamir } from '../../../../src/shamir/shamir'; +import { + bigIntFromBufferLE, + bigIntToBufferLE, + bigIntFromBufferBE, + bigIntToBufferBE, + clamp, +} from '../../../../src/util'; function makeKeypair(seed?: Buffer) { const privKey = seed ? Buffer.from(seed.subarray(0, 32)) : crypto.randomBytes(32); @@ -311,4 +320,207 @@ describe('EdDSA MPS DKG', function () { }, /DKG session is complete. Exporting the session is not allowed./); }); }); + + describe('Retrofit DKG (ed25519_dkg_round0_import)', function () { + const curve = new Ed25519Curve(); + const shamir = new Shamir(curve); + // 2^256 — same base used by the Eddsa class for chaincode arithmetic + const base = BigInt('0x010000000000000000000000000000000000000000000000000000000000000000'); + + /** + * Mirrors Eddsa.keyShare(index, 2, 3) + Eddsa.keyCombine() from sdk-core. + * Returns per-party EddsaRetrofitData with: + * s_i_0 = pShare.u (combined clamped scalar, distinct per party) + * expectedPk = pShare.y (aggregate Ed25519 public key, same across all parties) + * chainCode = pShare.chaincode (combined 32-byte chain code, same across all parties) + */ + function buildRetrofitData(seeds: Buffer[]): EddsaRetrofitData[] { + // Step 1: keyShare — derive per-party (u, y, chaincode, split_u) + type PartyRaw = { u: bigint; y: bigint; chaincode: bigint; splitU: Record }; + const n = seeds.length; + const parties: PartyRaw[] = seeds.map((seed) => { + const h = createHash('sha512').update(seed.subarray(0, 32)).digest(); + const u = clamp(bigIntFromBufferLE(h.subarray(0, 32) as Buffer)); + const y = curve.basePointMult(u); + const chaincode = bigIntFromBufferBE(seed.subarray(32, 64) as Buffer); + const { shares: splitU } = shamir.split(u, 2, n); + return { u, y, chaincode, splitU }; + }); + + // Step 2: keyCombine — aggregate y and chaincode; pick u_i for each party i + const aggY = parties.map((p) => p.y).reduce((acc, y) => curve.pointAdd(acc, y)); + const aggChaincode = parties.map((p) => p.chaincode).reduce((acc, cc) => (acc + cc) % base); + const expectedPk = bigIntToBufferLE(aggY, 32).toString('hex'); + // Eddsa.keyCombine stores pShare.chaincode as bigIntToBufferBE — match that encoding + const chainCode = bigIntToBufferBE(aggChaincode, 32).toString('hex'); + + return parties.map((party, idx) => ({ + s_i_0: bigIntToBufferLE(party.u, 32).toString('hex'), + expectedPk, + chainCode, + })); + } + + // Deterministic per-party seeds: 64 bytes each (first 32 = key seed, last 32 = chaincode). + // buildRetrofitData calls Ed25519Curve.basePointMult which requires libsodium to be + // initialized — run it inside before() rather than at describe-scope. + const seeds = [ + Buffer.from( + 'a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270' + + '9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', + 'hex' + ), + Buffer.from( + '33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe' + + 'b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381', + 'hex' + ), + Buffer.from( + 'ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a' + + '44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f', + 'hex' + ), + ]; + let retrofitUser: EddsaRetrofitData; + let retrofitBackup: EddsaRetrofitData; + let retrofitBitgo: EddsaRetrofitData; + + before(function () { + [retrofitUser, retrofitBackup, retrofitBitgo] = buildRetrofitData(seeds); + }); + + it('each party has a distinct s_i_0 but shared expectedPk and chainCode', function () { + assert.notStrictEqual(retrofitUser.s_i_0, retrofitBackup.s_i_0, 'user and backup s_i_0 must differ'); + assert.notStrictEqual(retrofitBackup.s_i_0, retrofitBitgo.s_i_0, 'backup and bitgo s_i_0 must differ'); + assert.strictEqual(retrofitUser.expectedPk, retrofitBackup.expectedPk, 'all parties share expectedPk'); + assert.strictEqual(retrofitBackup.expectedPk, retrofitBitgo.expectedPk, 'all parties share expectedPk'); + assert.strictEqual(retrofitUser.chainCode, retrofitBackup.chainCode, 'all parties share chainCode'); + }); + + it('should route getFirstMessage through ed25519_dkg_round0_import and all parties agree on public key', async function () { + const [user, backup, bitgo] = await generateEdDsaDKGKeyShares( + undefined, + undefined, + undefined, + retrofitUser, + retrofitBackup, + retrofitBitgo + ); + + const userPk = user.getSharePublicKey().toString('hex'); + const backupPk = backup.getSharePublicKey().toString('hex'); + const bitgoPk = bitgo.getSharePublicKey().toString('hex'); + + assert.strictEqual(userPk, backupPk, 'user and backup must agree on public key after retrofit DKG'); + assert.strictEqual(backupPk, bitgoPk, 'backup and bitgo must agree on public key after retrofit DKG'); + assert.strictEqual(userPk.length, 64, 'public key must be 32 bytes (64 hex chars)'); + }); + + it('retrofit DKG produces a different public key than a fresh DKG', async function () { + const [retrofitParty] = await generateEdDsaDKGKeyShares( + undefined, + undefined, + undefined, + retrofitUser, + retrofitBackup, + retrofitBitgo + ); + const [freshParty] = await generateEdDsaDKGKeyShares(); + + assert.notStrictEqual( + retrofitParty.getSharePublicKey().toString('hex'), + freshParty.getSharePublicKey().toString('hex'), + 'retrofit and fresh DKG should produce distinct public keys' + ); + }); + + it('retrofit DKG is deterministic: same retrofitData produces same public key', async function () { + const [run1] = await generateEdDsaDKGKeyShares( + undefined, + undefined, + undefined, + retrofitUser, + retrofitBackup, + retrofitBitgo + ); + const [run2] = await generateEdDsaDKGKeyShares( + undefined, + undefined, + undefined, + retrofitUser, + retrofitBackup, + retrofitBitgo + ); + + assert.strictEqual( + run1.getSharePublicKey().toString('hex'), + run2.getSharePublicKey().toString('hex'), + 'retrofit DKG must be deterministic: same inputs must produce same public key' + ); + }); + + it('session export/restore: restored party completes full retrofit DKG and agrees on public key', async function () { + const userKP = makeKeypair(); + const backupKP = makeKeypair(); + const bitgoKP = makeKeypair(); + + // --- Simulate party 0 persisting its session before round 0 --- + const user = new EddsaMPSDkg.DKG(3, 2, 0, retrofitUser); + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + + const session = user.getSession(); + const parsed = JSON.parse(session); + assert.deepStrictEqual(parsed.retrofitData, retrofitUser, 'getSession must include retrofitData'); + + // Restore party 0 into a fresh instance. + // initDkg loads the WASM module; restoreSession then overwrites state/keys from the blob. + const restoredUser = new EddsaMPSDkg.DKG(3, 2, 0); + await restoredUser.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + restoredUser.restoreSession(session); + + // --- Run parties 1 and 2 normally --- + const backup = new EddsaMPSDkg.DKG(3, 2, 1, retrofitBackup); + const bitgo = new EddsaMPSDkg.DKG(3, 2, 2, retrofitBitgo); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + // --- Round 0 --- + const r1Messages = [restoredUser.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + + // --- Round 1 --- + const r2Messages = [ + ...restoredUser.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + + // --- Round 2 (completes DKG) --- + restoredUser.handleIncomingMessages(r2Messages); + backup.handleIncomingMessages(r2Messages); + bitgo.handleIncomingMessages(r2Messages); + + // All three parties must agree on the same public key + const userPk = restoredUser.getSharePublicKey().toString('hex'); + const backupPk = backup.getSharePublicKey().toString('hex'); + const bitgoPk = bitgo.getSharePublicKey().toString('hex'); + + assert.strictEqual(userPk, backupPk, 'restored user and backup must agree on public key'); + assert.strictEqual(backupPk, bitgoPk, 'backup and bitgo must agree on public key'); + + // The public key must match the one from a non-restored retrofit run with the same inputs + const [refUser] = await generateEdDsaDKGKeyShares( + undefined, + undefined, + undefined, + retrofitUser, + retrofitBackup, + retrofitBitgo + ); + assert.strictEqual( + userPk, + refUser.getSharePublicKey().toString('hex'), + 'restored session must produce same public key as non-restored retrofit run' + ); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index ae55d5d6a8..e9199f91d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1059,10 +1059,10 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz" integrity sha512-KoXavJvyDHlEN+sWcigbgxYJtdFaU7gS0EkYQbNH4npVjNlzo6rL6gwjyWbyOy7oEs65DhpJ9vY5kRbE/bKiTQ== -"@bitgo/wasm-mps@1.11.0": - version "1.11.0" - resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.11.0.tgz#642f0a970f3545e6e4fa4b7df1920a7309952923" - integrity sha512-+RnpCdBpF41//duuvdeoreEzDMUANSB14H/wTRKOxLLOOPCA6WiXVKV4/20mGMvI1Gcx39xDdQM62M9a2kUwtA== +"@bitgo/wasm-mps@1.12.0": + version "1.12.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.12.0.tgz#03f9fc8eaa25d3dcb5af61915bba890759110c65" + integrity sha512-rude1gS5ml/I/qpkCoeBwvMbveNQp4cWxWzh3wUO4SsXebJMHVmGmWE27EsTBDEiaYI470q5H4aI/oyAfENOUg== "@bitgo/wasm-solana@^2.6.0": version "2.6.0"