From 6117b6d19a19f205119e1af7925efc5902a98ce0 Mon Sep 17 00:00:00 2001 From: BitGo Agent Date: Wed, 12 Aug 2026 07:01:13 +0000 Subject: [PATCH 1/2] 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" From 007e0c8162da6f690e257c5263fedf248858b96b Mon Sep 17 00:00:00 2001 From: Marzooqa Kather Date: Wed, 12 Aug 2026 08:48:04 +0000 Subject: [PATCH 2/2] feat(sdk-core): getUserAndBackupSession + createKeychains retrofit wiring Wire the retrofit path into EddsaMPCv2Utils.createKeychains(). When a retrofit payload is supplied, getUserAndBackupSession() initialises user and backup DKG sessions with EddsaRetrofitData (via getMpcV2RetrofitDataFromMpcV1Keys) instead of fresh randomness. The R1 request body includes walletId so the server-side isRound1RetrofitDKG() detection kicks in. Changes: - Add retrofit?: DecryptedRetrofitPayload to createKeychains() params - Add private async getUserAndBackupSession() that branches on retrofit - Replace inline DKG construction with getUserAndBackupSession() call - Extend sendKeyGenerationRound1/BySender payload type to allow walletId - Spread walletId into R1 payload when retrofit.walletId is present - Tests for getUserAndBackupSession (no-retrofit and retrofit paths) and for walletId presence/absence in the captured R1 payload Follows the same pattern as ecdsaMPCv2.ts getUserAndBackupSession (line 639) and the walletId spread (line 126-129). Ticket: WCI-1264 Session-Id: 597157b8-fee3-4515-b21b-4030e08362e8 Task-Id: 15dfe9c9-d429-4559-b0fc-771ada5e3c3a --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 31 +++- .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 146 ++++++++++++++++++ 2 files changed, 172 insertions(+), 5 deletions(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index c52b901933..9af9b079cd 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -16,7 +16,7 @@ import { EddsaMPCv2KeyGenCallbacks } from '../../../wallet/iWallets'; import { ed25519 } from '@noble/curves/ed25519'; import { EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc'; import { KeychainsTriplet } from '../../../baseCoin'; -import { AddKeychainOptions, Keychain, KeyType, WebauthnKeyEncryptionInfo } from '../../../keychain'; +import { AddKeychainOptions, DecryptedRetrofitPayload, Keychain, KeyType, WebauthnKeyEncryptionInfo } from '../../../keychain'; import { envRequiresBitgoPubGpgKeyConfig, isBitgoEddsaMpcv2PubKey } from '../../../tss/bitgoPubKeys'; import { getBitgoSignatureShare, getTxRequest, sendSignatureShareV2, sendTxRequest } from '../../../tss/common'; import { decodeWithCodec } from '../../codecs'; @@ -63,6 +63,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { passphrase: string; enterprise: string; originalPasscodeEncryptionCode?: string; + retrofit?: DecryptedRetrofitPayload; webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; // Wallet Safes v1 (@experimental): tags the resulting user/backup/bitgo root keys with this safe. @@ -93,8 +94,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj); // Create DKG sessions for user (party 0) and backup (party 1) - const userDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER); - const backupDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.BACKUP); + const { userDkg, backupDkg } = await this.getUserAndBackupSession(params.retrofit); // #region round 1 await userDkg.initDkg(userSk, [backupPk, bitgoPk]); @@ -116,6 +116,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { backupGpgPublicKey, userMsg1: userSignedMsg1, backupMsg1: backupSignedMsg1, + ...(params.retrofit?.walletId ? { walletId: params.retrofit.walletId } : {}), }, params.safeId ); @@ -459,7 +460,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { async sendKeyGenerationRound1( enterprise: string, - payload: EddsaMPCv2KeyGenRound1Request, + payload: EddsaMPCv2KeyGenRound1Request & { walletId?: string }, safeId?: string ): Promise { return this.sendKeyGenerationRound1BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise, safeId), payload); @@ -467,7 +468,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { async sendKeyGenerationRound1BySender( senderFn: EddsaMPCv2KeyGenSendFn, - payload: EddsaMPCv2KeyGenRound1Request + payload: EddsaMPCv2KeyGenRound1Request & { walletId?: string } ): Promise { return senderFn(MPCv2KeyGenStateEnum['MPCv2-R1'], payload); } @@ -1070,6 +1071,26 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { // #region retrofit + private async getUserAndBackupSession(retrofit?: DecryptedRetrofitPayload): Promise<{ + userDkg: EddsaMPSDkg.DKG; + backupDkg: EddsaMPSDkg.DKG; + }> { + if (retrofit) { + const { userRetrofitData, backupRetrofitData } = await this.getMpcV2RetrofitDataFromMpcV1Keys({ + mpcv1UserKeyShare: retrofit.decryptedUserKey, + mpcv1BackupKeyShare: retrofit.decryptedBackupKey, + }); + return { + userDkg: new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER, userRetrofitData), + backupDkg: new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.BACKUP, backupRetrofitData), + }; + } + return { + userDkg: new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER), + backupDkg: new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.BACKUP), + }; + } + async getMpcV2RetrofitDataFromMpcV1Keys(params: { mpcv1UserKeyShare: string; mpcv1BackupKeyShare: string }): Promise<{ userRetrofitData: MPSTypes.EddsaRetrofitData; backupRetrofitData: MPSTypes.EddsaRetrofitData; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 8d077e8d81..8ffa19fe8c 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -2467,3 +2467,149 @@ describe('EddsaMPCv2Utils.getMpcV2RetrofitDataFromMpcV1Keys', () => { ); }); }); + +describe('EddsaMPCv2Utils.getUserAndBackupSession', () => { + let utils: EddsaMPCv2Utils; + let userSigningMaterial: Record; + let backupSigningMaterial: Record; + + before(async () => { + const MPC = await getInitializedMpcInstance(); + const user = MPC.keyShare(1, 2, 3); + const backup = MPC.keyShare(2, 2, 3); + const bitgo = MPC.keyShare(3, 2, 3); + userSigningMaterial = { + uShare: user.uShare, + bitgoYShare: bitgo.yShares[1], + backupYShare: backup.yShares[1], + }; + backupSigningMaterial = { + uShare: backup.uShare, + bitgoYShare: bitgo.yShares[2], + userYShare: user.yShares[2], + }; + }); + + beforeEach(() => { + const mockBitGo = {} as unknown as BitGoBase; + const mockCoin = {} as unknown as IBaseCoin; + utils = new EddsaMPCv2Utils(mockBitGo, mockCoin); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns plain DKG sessions when retrofit is undefined', async () => { + const { userDkg, backupDkg } = await (utils as any).getUserAndBackupSession(undefined); + assert.ok(userDkg, 'user DKG should be created'); + assert.ok(backupDkg, 'backup DKG should be created'); + }); + + it('returns retrofit-seeded DKG sessions when retrofit payload is supplied', async () => { + const retrofit = { + decryptedUserKey: JSON.stringify(userSigningMaterial), + decryptedBackupKey: JSON.stringify(backupSigningMaterial), + walletId: 'wallet-123', + }; + const { userDkg, backupDkg } = await (utils as any).getUserAndBackupSession(retrofit); + assert.ok(userDkg, 'user DKG should be created with retrofit data'); + assert.ok(backupDkg, 'backup DKG should be created with retrofit data'); + }); +}); + +describe('EddsaMPCv2Utils.createKeychains with retrofit wiring', () => { + let utils: EddsaMPCv2Utils; + let userSigningMaterial: Record; + let backupSigningMaterial: Record; + let bitgoGpgPublicKeyArmored: string; + const enterprise = 'enterprise-id'; + const sessionId = 'session-001'; + const walletId = 'wallet-retrofit-123'; + + before(async () => { + const MPC = await getInitializedMpcInstance(); + const user = MPC.keyShare(1, 2, 3); + const backup = MPC.keyShare(2, 2, 3); + const bitgo = MPC.keyShare(3, 2, 3); + userSigningMaterial = { + uShare: user.uShare, + bitgoYShare: bitgo.yShares[1], + backupYShare: backup.yShares[1], + }; + backupSigningMaterial = { + uShare: backup.uShare, + bitgoYShare: bitgo.yShares[2], + userYShare: user.yShares[2], + }; + // Generate a real Ed25519 GPG key to stand in for the BitGo GPG key + const bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + bitgoGpgPublicKeyArmored = bitgoGpgKeyPair.publicKey; + }); + + beforeEach(() => { + const mockBitGo = { + getEnv: sinon.stub().returns('dev'), + encrypt: sinon.stub().resolves('encrypted'), + } as any; + const mockKeychains = { + add: sinon + .stub() + .callsFake((params: any) => + Promise.resolve({ id: `${params.source}-key-id`, commonKeychain: 'a'.repeat(128), isMPCv2: true }) + ), + }; + const mockCoin = { + keychains: sinon.stub().returns(mockKeychains), + } as any; + + utils = new EddsaMPCv2Utils(mockBitGo, mockCoin); + sinon.stub(utils, 'getBitgoGpgPubkeyBasedOnFeatureFlags' as any).resolves({ eddsaMpcv2PublicKey: null }); + // Use a real armored GPG public key so pgp.readKey() succeeds inside createKeychains + (utils as any).bitgoEddsaMpcv2PublicGpgKey = { armor: () => bitgoGpgPublicKeyArmored }; + sinon.stub(utils as any, 'addBitgoKeychain').resolves({ id: 'bitgo-key-id', commonKeychain: 'a'.repeat(128) }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('spreads walletId into round-1 payload when retrofit is provided', async () => { + const capturedPayloads: any[] = []; + sinon.stub(utils, 'sendKeyGenerationRound1').callsFake(async (_enterprise: string, payload: any) => { + capturedPayloads.push(payload); + // Return a bad bitgoMsg1 to short-circuit the ceremony after R1 capture + return { sessionId: sessionId as any, bitgoMsg1: { message: '', signature: '' } as any }; + }); + + const retrofit = { + decryptedUserKey: JSON.stringify(userSigningMaterial), + decryptedBackupKey: JSON.stringify(backupSigningMaterial), + walletId, + }; + + await assert.rejects( + () => utils.createKeychains({ passphrase: 'test', enterprise, retrofit }), + () => true + ); + + assert.strictEqual(capturedPayloads.length, 1, 'sendKeyGenerationRound1 should be called once'); + assert.strictEqual(capturedPayloads[0].walletId, walletId, 'walletId must be present in round-1 payload'); + }); + + it('omits walletId from round-1 payload when retrofit is absent', async () => { + const capturedPayloads: any[] = []; + sinon.stub(utils, 'sendKeyGenerationRound1').callsFake(async (_enterprise: string, payload: any) => { + capturedPayloads.push(payload); + return { sessionId: sessionId as any, bitgoMsg1: { message: '', signature: '' } as any }; + }); + + await assert.rejects( + () => utils.createKeychains({ passphrase: 'test', enterprise }), + () => true + ); + + assert.strictEqual(capturedPayloads.length, 1, 'sendKeyGenerationRound1 should be called once'); + assert.strictEqual(capturedPayloads[0].walletId, undefined, 'walletId must be absent when no retrofit'); + }); +});