|
1 | 1 | import * as assert from 'assert'; |
2 | 2 | import * as sinon from 'sinon'; |
3 | 3 | import * as pgp from 'openpgp'; |
| 4 | +import { randomBytes } from 'crypto'; |
4 | 5 | import { EddsaMPSDsg, MPSComms, MPSUtil } from '@bitgo/sdk-lib-mpc'; |
| 6 | +import * as sjcl from '@bitgo/sjcl'; |
5 | 7 | import { |
6 | 8 | EddsaMPCv2SignatureShareRound1Input, |
7 | 9 | EddsaMPCv2SignatureShareRound1Output, |
@@ -338,6 +340,158 @@ describe('EdDSA MPS DSG helper functions', async () => { |
338 | 340 | }); |
339 | 341 | }); |
340 | 342 |
|
| 343 | +describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { |
| 344 | + let eddsaMPCv2Utils: EddsaMPCv2Utils; |
| 345 | + let mockBitgo: BitGoBase; |
| 346 | + let userKeyShare: Buffer; |
| 347 | + |
| 348 | + const walletPassphrase = 'testPass'; |
| 349 | + const signableHex = 'deadbeef'; |
| 350 | + const derivationPath = 'm/0/0'; |
| 351 | + const expectedAdata = `${signableHex}:${derivationPath}`; |
| 352 | + const txRequest: TxRequest = { |
| 353 | + txRequestId: 'txreq-eddsa-round1', |
| 354 | + walletId: 'wallet-eddsa-round1', |
| 355 | + enterpriseId: 'enterprise-eddsa-round1', |
| 356 | + apiVersion: 'full', |
| 357 | + transactions: [ |
| 358 | + { |
| 359 | + unsignedTx: { |
| 360 | + signableHex, |
| 361 | + derivationPath, |
| 362 | + serializedTxHex: signableHex, |
| 363 | + }, |
| 364 | + signatureShares: [], |
| 365 | + }, |
| 366 | + ], |
| 367 | + intent: { intentType: 'payment' }, |
| 368 | + unsignedTxs: [], |
| 369 | + } as unknown as TxRequest; |
| 370 | + |
| 371 | + before('generate EdDSA user key share', async () => { |
| 372 | + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); |
| 373 | + userKeyShare = userDkg.getKeyShare(); |
| 374 | + }); |
| 375 | + |
| 376 | + beforeEach(() => { |
| 377 | + mockBitgo = { |
| 378 | + encrypt: sinon.stub().callsFake((params) => { |
| 379 | + const salt = randomBytes(8); |
| 380 | + const iv = randomBytes(16); |
| 381 | + return sjcl.encrypt(params.password, params.input, { |
| 382 | + salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4))], |
| 383 | + iv: [ |
| 384 | + bytesToWord(iv.subarray(0, 4)), |
| 385 | + bytesToWord(iv.subarray(4, 8)), |
| 386 | + bytesToWord(iv.subarray(8, 12)), |
| 387 | + bytesToWord(iv.subarray(12, 16)), |
| 388 | + ], |
| 389 | + adata: params.adata, |
| 390 | + }); |
| 391 | + }), |
| 392 | + } as unknown as BitGoBase; |
| 393 | + |
| 394 | + const mockCoin = { |
| 395 | + getMPCAlgorithm: sinon.stub().returns('eddsa'), |
| 396 | + } as unknown as IBaseCoin; |
| 397 | + |
| 398 | + eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin); |
| 399 | + }); |
| 400 | + |
| 401 | + it('should create a round-1 share and encrypted SJCL session payload', async () => { |
| 402 | + const result = await eddsaMPCv2Utils.createOfflineRound1Share({ |
| 403 | + txRequest, |
| 404 | + prv: userKeyShare.toString('base64'), |
| 405 | + walletPassphrase, |
| 406 | + }); |
| 407 | + |
| 408 | + assert.strictEqual(result.signatureShareRound1.from, SignatureShareType.USER); |
| 409 | + assert.strictEqual(result.signatureShareRound1.to, SignatureShareType.BITGO); |
| 410 | + assert.ok(result.userGpgPubKey.includes('BEGIN PGP PUBLIC KEY BLOCK')); |
| 411 | + assert.ok(JSON.parse(result.encryptedRound1Session).ct, 'encryptedRound1Session should be an SJCL JSON blob'); |
| 412 | + assert.ok(JSON.parse(result.encryptedUserGpgPrvKey).ct, 'encryptedUserGpgPrvKey should be an SJCL JSON blob'); |
| 413 | + |
| 414 | + const parsedShare = decodeWithCodec( |
| 415 | + EddsaMPCv2SignatureShareRound1Input, |
| 416 | + JSON.parse(result.signatureShareRound1.share), |
| 417 | + 'EddsaMPCv2SignatureShareRound1Input' |
| 418 | + ); |
| 419 | + assert.strictEqual(parsedShare.type, 'round1Input'); |
| 420 | + assert.ok(parsedShare.data.msg1.message, 'msg1.message should be set'); |
| 421 | + assert.ok(parsedShare.data.msg1.signature, 'msg1.signature should be set'); |
| 422 | + |
| 423 | + const encryptedRound1Session = JSON.parse(result.encryptedRound1Session); |
| 424 | + const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey); |
| 425 | + assert.strictEqual( |
| 426 | + decodeURIComponent(encryptedRound1Session.adata), |
| 427 | + `MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`, |
| 428 | + 'round-1 session adata should bind the signing context' |
| 429 | + ); |
| 430 | + assert.strictEqual( |
| 431 | + decodeURIComponent(encryptedUserGpgPrvKey.adata), |
| 432 | + `MPS_DSG_SIGNING_USER_GPG_KEY:${expectedAdata}`, |
| 433 | + 'GPG private key adata should bind the signing context' |
| 434 | + ); |
| 435 | + |
| 436 | + const sessionPayload = JSON.parse(sjcl.decrypt(walletPassphrase, result.encryptedRound1Session)); |
| 437 | + assert.ok(sessionPayload.dsgSession, 'dsgSession should be persisted for round 2'); |
| 438 | + assert.ok(sessionPayload.userMsgPayload, 'userMsgPayload should be persisted for round 2'); |
| 439 | + }); |
| 440 | + |
| 441 | + it('should use v2 encryption when encryptedPrv is a v2 envelope', async () => { |
| 442 | + const encrypt = sinon |
| 443 | + .stub() |
| 444 | + .callsFake((input: string, adata: string) => Promise.resolve(JSON.stringify({ v: 2, input, adata }))); |
| 445 | + const destroy = sinon.stub(); |
| 446 | + const createEncryptionSession = sinon.stub().resolves({ encrypt, destroy }); |
| 447 | + mockBitgo.createEncryptionSession = createEncryptionSession; |
| 448 | + |
| 449 | + const result = await eddsaMPCv2Utils.createOfflineRound1Share({ |
| 450 | + txRequest, |
| 451 | + prv: userKeyShare.toString('base64'), |
| 452 | + walletPassphrase, |
| 453 | + encryptedPrv: JSON.stringify({ v: 2 }), |
| 454 | + }); |
| 455 | + |
| 456 | + sinon.assert.calledOnce(createEncryptionSession); |
| 457 | + assert.strictEqual(createEncryptionSession.getCall(0).args[0], walletPassphrase); |
| 458 | + sinon.assert.notCalled(mockBitgo.encrypt as sinon.SinonStub); |
| 459 | + sinon.assert.calledTwice(encrypt); |
| 460 | + sinon.assert.calledOnce(destroy); |
| 461 | + |
| 462 | + const encryptedRound1Session = JSON.parse(result.encryptedRound1Session); |
| 463 | + const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey); |
| 464 | + assert.strictEqual(encryptedRound1Session.v, 2); |
| 465 | + assert.strictEqual(encryptedRound1Session.adata, `MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`); |
| 466 | + assert.strictEqual(encryptedUserGpgPrvKey.v, 2); |
| 467 | + assert.strictEqual(encryptedUserGpgPrvKey.adata, `MPS_DSG_SIGNING_USER_GPG_KEY:${expectedAdata}`); |
| 468 | + |
| 469 | + const sessionPayload = JSON.parse(encryptedRound1Session.input); |
| 470 | + assert.ok(sessionPayload.dsgSession, 'dsgSession should be persisted for round 2'); |
| 471 | + assert.ok(sessionPayload.userMsgPayload, 'userMsgPayload should be persisted for round 2'); |
| 472 | + }); |
| 473 | + |
| 474 | + it('should propagate the tx-only guard when transactions are missing', async () => { |
| 475 | + await assert.rejects( |
| 476 | + () => |
| 477 | + eddsaMPCv2Utils.createOfflineRound1Share({ |
| 478 | + txRequest: { ...txRequest, transactions: undefined } as unknown as TxRequest, |
| 479 | + prv: userKeyShare.toString('base64'), |
| 480 | + walletPassphrase, |
| 481 | + }), |
| 482 | + /Unable to find transactions in txRequest/ |
| 483 | + ); |
| 484 | + }); |
| 485 | +}); |
| 486 | + |
| 487 | +function bytesToWord(bytes?: Uint8Array | number[]): number { |
| 488 | + if (!(bytes instanceof Uint8Array) || bytes.length !== 4) { |
| 489 | + throw new Error('bytes must be a Uint8Array with length 4'); |
| 490 | + } |
| 491 | + |
| 492 | + return bytes.reduce((num, byte) => num * 0x100 + byte, 0); |
| 493 | +} |
| 494 | + |
341 | 495 | describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => { |
342 | 496 | let sandbox: sinon.SinonSandbox; |
343 | 497 | let eddsaMPCv2Utils: EddsaMPCv2Utils; |
|
0 commit comments