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 473e3b1549..a67512e633 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -53,6 +53,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; 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'; /** @inheritdoc */ async createKeychains(params: { @@ -558,6 +559,56 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { // #region external signer + // #region KeyGenRound1Share + async createOfflineKeyGenRound1Share(params: { + walletPassphrase: string; + encryptedUserGpgPrvKey: string; + bitgoGpgPubKey: string; + counterPartyGpgPubKey: string; + partyId?: MPCv2PartiesEnum; + }): Promise<{ + signedMsg1: MPSTypes.MPSSignedMessage; + encryptedRound1Session: string; + }> { + const { walletPassphrase, encryptedUserGpgPrvKey, bitgoGpgPubKey, counterPartyGpgPubKey } = params; + const partyId = params.partyId ?? MPCv2PartiesEnum.USER; + + const decryptedGpgPrvKey = isV2Envelope(encryptedUserGpgPrvKey) + ? await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase }) + : this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase }); + const gpgPrvKey = await pgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey }); + + const [, ownSk] = await MPSComms.extractEd25519KeyPair(gpgPrvKey); + + const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoGpgPubKey }); + const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj); + + const counterPartyKeyObj = await pgp.readKey({ armoredKey: counterPartyGpgPubKey }); + const counterPartyPk = await MPSComms.extractEd25519PublicKey(counterPartyKeyObj); + + const dkg = new EddsaMPSDkg.DKG(3, 2, partyId); + await dkg.initDkg(ownSk, [counterPartyPk, bitgoPk]); + + const msg1 = dkg.getFirstMessage(); + const signedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(msg1.payload), gpgPrvKey); + + const sessionPayload = JSON.stringify({ + dkgSession: dkg.getSession(), + ownMsgPayload: Buffer.from(msg1.payload).toString('base64'), + ownMsgFrom: msg1.from, + }); + + const encryptedRound1Session = await this.bitgo.encryptAsync({ + input: sessionPayload, + password: walletPassphrase, + adata: EddsaMPCv2Utils.MPS_DKG_KEYGEN_ROUND1_STATE, + encryptionVersion: 1, + }); + + return { signedMsg1, encryptedRound1Session }; + } + // #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 4fe1e2a48b..b65c33c973 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 @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as pgp from 'openpgp'; import { randomBytes } from 'crypto'; -import { EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc'; +import { EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc'; import { ed25519 } from '@noble/curves/ed25519'; import * as sjcl from '@bitgo/sjcl'; import { @@ -608,6 +608,135 @@ describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { }); }); +describe('EddsaMPCv2Utils.createOfflineKeyGenRound1Share', () => { + 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'); + }); + + beforeEach(() => { + 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, + }); + }; + 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)), + } as unknown as BitGoBase; + + const mockCoin = { + getMPCAlgorithm: sinon.stub().returns('eddsa'), + } as unknown as IBaseCoin; + + eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin); + }); + + it('should produce valid signedMsg1 and encrypted round-1 session', async () => { + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + }); + + assert.ok(result.signedMsg1.message, 'signedMsg1.message should be set'); + assert.ok( + result.signedMsg1.signature.includes('BEGIN PGP SIGNATURE'), + 'signedMsg1.signature should be PGP armored' + ); + assert.ok(JSON.parse(result.encryptedRound1Session).ct, 'encryptedRound1Session should be an SJCL JSON blob'); + }); + + it('encryptedRound1Session should only be decryptable with correct walletPassphrase', async () => { + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + }); + + const decrypted = sjcl.decrypt(walletPassphrase, result.encryptedRound1Session); + const session = JSON.parse(decrypted); + assert.ok(session.dkgSession, 'dkgSession should be persisted'); + assert.ok(session.ownMsgPayload, 'ownMsgPayload should be persisted'); + assert.strictEqual(typeof session.ownMsgFrom, 'number', 'ownMsgFrom should be a number'); + + assert.throws( + () => sjcl.decrypt('wrongpassphrase', result.encryptedRound1Session), + 'should throw on wrong passphrase' + ); + }); + + it('should restore DKG session and handle round-2 messages after round 1', async () => { + const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey); + + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({ + walletPassphrase, + encryptedUserGpgPrvKey, + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + }); + + const decrypted = sjcl.decrypt(walletPassphrase, result.encryptedRound1Session); + const { dkgSession, ownMsgPayload, ownMsgFrom } = JSON.parse(decrypted) as { + dkgSession: string; + ownMsgPayload: string; + ownMsgFrom: number; + }; + + const restoredDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER); + await restoredDkg.restoreSession(dkgSession); + + const ownMsg1: MPSTypes.DeserializedMessage = { + from: ownMsgFrom, + payload: new Uint8Array(Buffer.from(ownMsgPayload, 'base64')), + }; + assert.ok(ownMsg1.payload.length > 0, 'restored payload should be non-empty'); + + const userGpgKey = await pgp.readKey({ armoredKey: userGpgKeyPair.publicKey }); + const rawBytes = await MPSComms.verifyMpsMessage(result.signedMsg1, userGpgKey); + assert.ok(rawBytes.length > 0, 'signedMsg1 should verify against user GPG key'); + }); + + it('should reject invalid encryptedUserGpgPrvKey', async () => { + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineKeyGenRound1Share({ + walletPassphrase, + encryptedUserGpgPrvKey: sjcl.encrypt(walletPassphrase, 'not-a-gpg-key'), + bitgoGpgPubKey: bitgoGpgKeyPair.publicKey, + counterPartyGpgPubKey: backupGpgKeyPair.publicKey, + }), + 'should throw when GPG key cannot be parsed' + ); + }); +}); + describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { let eddsaMPCv2Utils: EddsaMPCv2Utils; let mockBitgo: BitGoBase;