|
1 | 1 | import assert from 'assert'; |
2 | | -import crypto from 'crypto'; |
| 2 | +import crypto, { createHash } from 'crypto'; |
3 | 3 | 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'; |
5 | 5 | 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'; |
6 | 15 |
|
7 | 16 | function makeKeypair(seed?: Buffer) { |
8 | 17 | const privKey = seed ? Buffer.from(seed.subarray(0, 32)) : crypto.randomBytes(32); |
@@ -311,4 +320,206 @@ describe('EdDSA MPS DKG', function () { |
311 | 320 | }, /DKG session is complete. Exporting the session is not allowed./); |
312 | 321 | }); |
313 | 322 | }); |
| 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 | + }); |
314 | 525 | }); |
0 commit comments