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 566bfa4a2d..af1c91e278 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -55,6 +55,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE'; private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE'; private static readonly MPS_DKG_KEYGEN_ROUND1_STATE = 'MPS_DKG_KEYGEN_ROUND1_STATE'; + private static readonly MPS_DKG_KEYGEN_ROUND2_STATE = 'MPS_DKG_KEYGEN_ROUND2_STATE'; /** @inheritdoc */ async createKeychains(params: { @@ -701,6 +702,93 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { } // #endregion + // #region KeyGenRound2Share + async createOfflineKeyGenRound2Share(params: { + walletPassphrase: string; + encryptedUserGpgPrvKey: string; + encryptedRound1Session: string; + bitgoGpgPubKey: string; + counterPartyGpgPubKey: string; + bitgoMsg1: MPSTypes.MPSSignedMessage; + counterPartyMsg1: MPSTypes.MPSSignedMessage; + }): Promise<{ + signedMsg2: MPSTypes.MPSSignedMessage; + encryptedRound2Session: string; + }> { + const { + walletPassphrase, + encryptedUserGpgPrvKey, + encryptedRound1Session, + bitgoGpgPubKey, + counterPartyGpgPubKey, + bitgoMsg1, + counterPartyMsg1, + } = params; + + const decryptedGpgPrvKey = await this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase }); + const gpgPrvKey = await pgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey }); + + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoEddsaMpcv2PubKey(bitgoGpgPubKey), 'Invalid BitGo GPG public key'); + } + + const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoGpgPubKey }); + const counterPartyKeyObj = await pgp.readKey({ armoredKey: counterPartyGpgPubKey }); + + const decryptedRound1Session = await this.bitgo.decrypt({ + input: encryptedRound1Session, + password: walletPassphrase, + }); + + const { dkgSession, ownMsgPayload, ownMsgFrom } = JSON.parse(decryptedRound1Session) as { + dkgSession: string; + ownMsgPayload: string; + ownMsgFrom: number; + }; + + const dkg = new EddsaMPSDkg.DKG(3, 2, ownMsgFrom); + await dkg.restoreSession(dkgSession); + + const bitgoRawMsg1Bytes = await MPSComms.verifyMpsMessage(bitgoMsg1, bitgoKeyObj); + const bitgoDeserializedMsg1: MPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.BITGO, + payload: new Uint8Array(bitgoRawMsg1Bytes), + }; + + const counterPartyRawMsg1Bytes = await MPSComms.verifyMpsMessage(counterPartyMsg1, counterPartyKeyObj); + const counterPartyDeserializedMsg1: MPSTypes.DeserializedMessage = { + from: ownMsgFrom === MPCv2PartiesEnum.USER ? MPCv2PartiesEnum.BACKUP : MPCv2PartiesEnum.USER, + payload: new Uint8Array(counterPartyRawMsg1Bytes), + }; + + const ownMsg1: MPSTypes.DeserializedMessage = { + from: ownMsgFrom, + payload: new Uint8Array(Buffer.from(ownMsgPayload, 'base64')), + }; + + const round2Msgs = dkg.handleIncomingMessages([ownMsg1, counterPartyDeserializedMsg1, bitgoDeserializedMsg1]); + assert(round2Msgs.length === 1, 'DKG round 2 should produce exactly one round 2 message'); + + const ownMsg2 = round2Msgs[0]; + const signedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(ownMsg2.payload), gpgPrvKey); + + const sessionPayload = JSON.stringify({ + dkgSession: dkg.getSession(), + ownMsgPayload: Buffer.from(ownMsg2.payload).toString('base64'), + ownMsgFrom: ownMsg2.from, + }); + + const encryptedRound2Session = await this.bitgo.encrypt({ + input: sessionPayload, + password: walletPassphrase, + adata: `${EddsaMPCv2Utils.MPS_DKG_KEYGEN_ROUND2_STATE}:${ownMsgFrom}`, + encryptionVersion: 1, + }); + + return { signedMsg2, encryptedRound2Session }; + } + // #endregion + // #region Round1Share async createOfflineRound1Share(params: { txRequest: TxRequest; 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 664eab8e09..6883e0db7a 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 @@ -639,10 +639,8 @@ describe('EddsaMPCv2Utils.createOfflineKeyGenRound1Share', () => { }); }; mockBitgo = { - encrypt: sinon.stub().callsFake(sjclEncrypt), - encryptAsync: sinon.stub().callsFake(async (params) => sjclEncrypt(params)), - decrypt: sinon.stub().callsFake((params) => sjcl.decrypt(params.password, params.input)), - decryptAsync: sinon.stub().callsFake(async (params) => sjcl.decrypt(params.password, params.input)), + encrypt: sinon.stub().callsFake(async (params) => sjclEncrypt(params)), + decrypt: sinon.stub().callsFake(async (params) => sjcl.decrypt(params.password, params.input)), getEnv: sinon.stub().returns('mock'), } as unknown as BitGoBase; @@ -777,6 +775,233 @@ describe('EddsaMPCv2Utils.createOfflineKeyGenRound1Share', () => { }); }); +describe('EddsaMPCv2Utils.createOfflineKeyGenRound2Share', () => { + let eddsaMPCv2Utils: EddsaMPCv2Utils; + let mockBitgo: BitGoBase; + let userGpgKeyPair: pgp.SerializedKeyPair; + let backupGpgKeyPair: pgp.SerializedKeyPair; + let bitgoGpgKeyPair: pgp.SerializedKeyPair; + + const walletPassphrase = 'testPass'; + + before('generate GPG key pairs', async () => { + userGpgKeyPair = await generateGPGKeyPair('ed25519'); + backupGpgKeyPair = await generateGPGKeyPair('ed25519'); + bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + }); + + function makeMockBitgo(): BitGoBase { + const sjclEncrypt = (params: { password: string; input: string; adata?: string }) => { + const salt = randomBytes(8); + const iv = randomBytes(16); + return sjcl.encrypt(params.password, params.input, { + salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4))], + iv: [ + bytesToWord(iv.subarray(0, 4)), + bytesToWord(iv.subarray(4, 8)), + bytesToWord(iv.subarray(8, 12)), + bytesToWord(iv.subarray(12, 16)), + ], + adata: params.adata, + }); + }; + return { + encrypt: sinon.stub().callsFake(async (params) => sjclEncrypt(params)), + decrypt: sinon.stub().callsFake(async (params) => sjcl.decrypt(params.password, params.input)), + getEnv: sinon.stub().returns('mock'), + } as unknown as BitGoBase; + } + + beforeEach(() => { + mockBitgo = makeMockBitgo(); + const mockCoin = { getMPCAlgorithm: sinon.stub().returns('eddsa') } as unknown as IBaseCoin; + eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin); + }); + + async function runRound1(partyId: MPCv2PartiesEnum.USER | MPCv2PartiesEnum.BACKUP = MPCv2PartiesEnum.USER): Promise<{ + result: { signedMsg1: MPSTypes.MPSSignedMessage; encryptedRound1Session: string }; + ownGpgKeyPair: pgp.SerializedKeyPair; + counterPartyGpgKeyPair: pgp.SerializedKeyPair; + }> { + const ownGpgKeyPair = partyId === MPCv2PartiesEnum.USER ? userGpgKeyPair : backupGpgKeyPair; + const counterPartyGpgKeyPair = partyId === MPCv2PartiesEnum.USER ? backupGpgKeyPair : userGpgKeyPair; + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, ownGpgKeyPair.privateKey); + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: counterPartyGpgKeyPair.publicKey, + partyId, + }); + return { result, ownGpgKeyPair, counterPartyGpgKeyPair }; + } + + async function buildCounterPartyAndBitgoMsgs( + _userRound1Result: MPSTypes.MPSSignedMessage, + backupRound1Result: MPSTypes.MPSSignedMessage + ): Promise<{ bitgoMsg1: MPSTypes.MPSSignedMessage; counterPartyMsg1ForUser: MPSTypes.MPSSignedMessage }> { + const bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); + + const userGpgPubKeyObj = await pgp.readKey({ armoredKey: userGpgKeyPair.publicKey }); + const userPk = await MPSComms.extractEd25519PublicKey(userGpgPubKeyObj); + + const backupGpgPubKeyObj = await pgp.readKey({ armoredKey: backupGpgKeyPair.publicKey }); + const backupPk = await MPSComms.extractEd25519PublicKey(backupGpgPubKeyObj); + + const bitgoDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.BITGO); + await bitgoDkg.initDkg((await MPSComms.extractEd25519KeyPair(bitgoGpgPrivKey))[1], [userPk, backupPk]); + const bitgoRawMsg1 = bitgoDkg.getFirstMessage(); + const bitgoMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoRawMsg1.payload), bitgoGpgPrivKey); + + return { bitgoMsg1, counterPartyMsg1ForUser: backupRound1Result }; + } + + it('Round1 → Round2 produces valid { signedMsg2, encryptedRound2Session }', async () => { + // Run round 1 for all three parties + const { result: userRound1 } = await runRound1(MPCv2PartiesEnum.USER); + const { result: backupRound1 } = await runRound1(MPCv2PartiesEnum.BACKUP); + + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + const { bitgoMsg1, counterPartyMsg1ForUser } = await buildCounterPartyAndBitgoMsgs( + userRound1.signedMsg1, + backupRound1.signedMsg1 + ); + + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound2Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + encryptedRound1Session: userRound1.encryptedRound1Session, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + bitgoMsg1, + counterPartyMsg1: counterPartyMsg1ForUser, + }); + + assert.ok(result.signedMsg2.message, 'signedMsg2.message should be set'); + assert.ok( + result.signedMsg2.signature.includes('BEGIN PGP SIGNATURE'), + 'signedMsg2.signature should be PGP armored' + ); + assert.ok(JSON.parse(result.encryptedRound2Session).ct, 'encryptedRound2Session should be an SJCL JSON blob'); + + const sessionPayload = JSON.parse(sjcl.decrypt(walletPassphrase, result.encryptedRound2Session)); + assert.ok(sessionPayload.dkgSession, 'dkgSession should be persisted for finalize'); + assert.ok(sessionPayload.ownMsgPayload, 'ownMsgPayload should be persisted for finalize'); + assert.strictEqual(typeof sessionPayload.ownMsgFrom, 'number', 'ownMsgFrom should be a number'); + }); + + it('encryptedRound2Session should only be decryptable with correct walletPassphrase', async () => { + const { result: userRound1 } = await runRound1(MPCv2PartiesEnum.USER); + const { result: backupRound1 } = await runRound1(MPCv2PartiesEnum.BACKUP); + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + const { bitgoMsg1, counterPartyMsg1ForUser } = await buildCounterPartyAndBitgoMsgs( + userRound1.signedMsg1, + backupRound1.signedMsg1 + ); + + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound2Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + encryptedRound1Session: userRound1.encryptedRound1Session, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + bitgoMsg1, + counterPartyMsg1: counterPartyMsg1ForUser, + }); + + assert.throws( + () => sjcl.decrypt('wrongpassphrase', result.encryptedRound2Session), + 'should fail to decrypt with wrong passphrase' + ); + }); + + it('should reject tampered incoming messages (signature verification failure)', async () => { + const { result: userRound1 } = await runRound1(MPCv2PartiesEnum.USER); + const { result: backupRound1 } = await runRound1(MPCv2PartiesEnum.BACKUP); + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + const { bitgoMsg1 } = await buildCounterPartyAndBitgoMsgs(userRound1.signedMsg1, backupRound1.signedMsg1); + + const tamperedMsg: MPSTypes.MPSSignedMessage = { + message: Buffer.from('tampered').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', + }; + + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineKeyGenRound2Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + encryptedRound1Session: userRound1.encryptedRound1Session, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + bitgoMsg1, + counterPartyMsg1: tamperedMsg, + }), + 'should throw on tampered counterParty message' + ); + }); + + it('should reject wrong walletPassphrase for session decryption', async () => { + const { result: userRound1 } = await runRound1(MPCv2PartiesEnum.USER); + const { result: backupRound1 } = await runRound1(MPCv2PartiesEnum.BACKUP); + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + const { bitgoMsg1, counterPartyMsg1ForUser } = await buildCounterPartyAndBitgoMsgs( + userRound1.signedMsg1, + backupRound1.signedMsg1 + ); + + const wrongPassphraseBitgo = makeMockBitgo(); + (wrongPassphraseBitgo.decrypt as sinon.SinonStub).callsFake(() => { + throw new Error('ccm: tag does not match'); + }); + const mockCoin = { getMPCAlgorithm: sinon.stub().returns('eddsa') } as unknown as IBaseCoin; + const utilsWrongPass = new EddsaMPCv2Utils(wrongPassphraseBitgo, mockCoin); + + await assert.rejects( + () => + utilsWrongPass.createOfflineKeyGenRound2Share({ + walletPassphrase: 'wrongpassphrase', + encryptedUserGpgPrvKey, + encryptedRound1Session: userRound1.encryptedRound1Session, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + bitgoMsg1, + counterPartyMsg1: counterPartyMsg1ForUser, + }), + /ccm: tag does not match/ + ); + }); + + it('should reject non-BitGo pub key when env requires BitGo pub key config', async () => { + const { result: userRound1 } = await runRound1(MPCv2PartiesEnum.USER); + const { result: backupRound1 } = await runRound1(MPCv2PartiesEnum.BACKUP); + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + const { bitgoMsg1, counterPartyMsg1ForUser } = await buildCounterPartyAndBitgoMsgs( + userRound1.signedMsg1, + backupRound1.signedMsg1 + ); + + const prodMockBitgo = makeMockBitgo(); + (prodMockBitgo.getEnv as sinon.SinonStub).returns('prod'); + const mockCoin = { getMPCAlgorithm: sinon.stub().returns('eddsa') } as unknown as IBaseCoin; + const prodUtils = new EddsaMPCv2Utils(prodMockBitgo, mockCoin); + + await assert.rejects( + () => + prodUtils.createOfflineKeyGenRound2Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + encryptedRound1Session: userRound1.encryptedRound1Session, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + bitgoMsg1, + counterPartyMsg1: counterPartyMsg1ForUser, + }), + /Invalid BitGo GPG public key/ + ); + }); +}); + describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { let eddsaMPCv2Utils: EddsaMPCv2Utils; let mockBitgo: BitGoBase; 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..ea3f9042d3 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts @@ -274,7 +274,8 @@ export class DKG { * Restores a previously exported session. Allows the protocol to continue * from where it left off, as if the round state was loaded from a database. */ - restoreSession(session: string): void { + async restoreSession(session: string): Promise { + await this.loadWasmMps(); const data = JSON.parse(session); this.dkgStateBytes = data.dkgStateBytes ? Buffer.from(data.dkgStateBytes, 'base64') : null; this.dkgState = data.dkgRound; 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..72460aa6ba 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts @@ -286,9 +286,9 @@ describe('EdDSA MPS DKG', function () { const restoredBackup = new EddsaMPSDkg.DKG(3, 2, 1); const restoredBitgo = new EddsaMPSDkg.DKG(3, 2, 2); - restoredUser.restoreSession(userSession); - restoredBackup.restoreSession(backupSession); - restoredBitgo.restoreSession(bitgoSession); + await restoredUser.restoreSession(userSession); + await restoredBackup.restoreSession(backupSession); + await restoredBitgo.restoreSession(bitgoSession); assert.strictEqual(restoredUser.getState(), user.getState(), 'Restored state should match original'); assert.strictEqual(restoredBackup.getState(), backup.getState(), 'Restored backup state should match original');