Skip to content
Merged
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
55 changes: 55 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Comment thread
Marzooqa marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -558,6 +559,60 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {

// #region external signer

// #region KeyGenRound1Share
async createOfflineKeyGenRound1Share(params: {
walletPassphrase: string;
encryptedUserGpgPrvKey: string;
bitgoGpgPubKey: string;
counterPartyGpgPubKey: string;
partyId?: MPCv2PartiesEnum.USER | MPCv2PartiesEnum.BACKUP;
Comment thread
Marzooqa marked this conversation as resolved.
}): 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);

if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
assert(isBitgoEddsaMpcv2PubKey(bitgoGpgPubKey), 'Invalid BitGo GPG public key');
}

const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoGpgPubKey });
Comment thread
Marzooqa marked this conversation as resolved.
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}:${partyId}`,
encryptionVersion: 1,
});

return { signedMsg1, encryptedRound1Session };
}
// #endregion

// #region Round1Share
async createOfflineRound1Share(params: {
txRequest: TxRequest;
Expand Down
171 changes: 170 additions & 1 deletion modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Comment thread
Marzooqa marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -608,6 +608,175 @@ describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
});
});

describe('EddsaMPCv2Utils.createOfflineKeyGenRound1Share', () => {
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');
});

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)),
getEnv: sinon.stub().returns('mock'),
Comment thread
Marzooqa marked this conversation as resolved.
} 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'
);
});

it('USER and BACKUP partyId produce different encrypted session state', async () => {
const userEncryptedGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey);

const userResult = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
walletPassphrase,
encryptedUserGpgPrvKey: userEncryptedGpgPrvKey,
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
counterPartyGpgPubKey: backupGpgKeyPair.publicKey,
partyId: MPCv2PartiesEnum.USER,
});

const backupEncryptedGpgPrvKey2 = sjcl.encrypt(walletPassphrase, backupGpgKeyPair.privateKey);
const backupResult = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
walletPassphrase,
encryptedUserGpgPrvKey: backupEncryptedGpgPrvKey2,
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
counterPartyGpgPubKey: userGpgKeyPair.publicKey,
partyId: MPCv2PartiesEnum.BACKUP,
});

const userSession = JSON.parse(sjcl.decrypt(walletPassphrase, userResult.encryptedRound1Session));
const backupSession = JSON.parse(sjcl.decrypt(walletPassphrase, backupResult.encryptedRound1Session));

assert.strictEqual(userSession.ownMsgFrom, MPCv2PartiesEnum.USER);
assert.strictEqual(backupSession.ownMsgFrom, MPCv2PartiesEnum.BACKUP);
assert.notStrictEqual(userSession.dkgSession, backupSession.dkgSession, 'Sessions should differ between parties');

// Sessions encrypted with party-bound adata — tampered adata fails CCM tag check
const tamperedSession = (() => {
const parsed = JSON.parse(userResult.encryptedRound1Session);
parsed.adata = encodeURIComponent(`MPS_DKG_KEYGEN_ROUND1_STATE:${MPCv2PartiesEnum.BACKUP}`);
return JSON.stringify(parsed);
})();
assert.throws(
() => sjcl.decrypt(walletPassphrase, tamperedSession),
'should fail to decrypt with wrong party adata'
);
});
});

describe('EddsaMPCv2Utils.createOfflineRound2Share', () => {
let eddsaMPCv2Utils: EddsaMPCv2Utils;
let mockBitgo: BitGoBase;
Expand Down
Loading