Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
233 changes: 229 additions & 4 deletions modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -777,6 +775,233 @@ describe('EddsaMPCv2Utils.createOfflineKeyGenRound1Share', () => {
});
});

describe('EddsaMPCv2Utils.createOfflineKeyGenRound2Share', () => {
let eddsaMPCv2Utils: EddsaMPCv2Utils;
let mockBitgo: BitGoBase;
let userGpgKeyPair: pgp.SerializedKeyPair<string>;
let backupGpgKeyPair: pgp.SerializedKeyPair<string>;
let bitgoGpgKeyPair: pgp.SerializedKeyPair<string>;

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<string>;
counterPartyGpgKeyPair: pgp.SerializedKeyPair<string>;
}> {
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;
Expand Down
3 changes: 2 additions & 1 deletion modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.loadWasmMps();
const data = JSON.parse(session);
this.dkgStateBytes = data.dkgStateBytes ? Buffer.from(data.dkgStateBytes, 'base64') : null;
this.dkgState = data.dkgRound;
Expand Down
6 changes: 3 additions & 3 deletions modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading