Skip to content
Open
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
51 changes: 51 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,56 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {

// #region external signer

// #region KeyGenRound1Share
async createOfflineKeyGenRound1Share(params: {
walletPassphrase: string;
encryptedUserGpgPrvKey: string;
bitgoGpgPubKey: string;
counterPartyGpgPubKey: string;
partyId?: MPCv2PartiesEnum;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're using the full enum here which also includes BITGO

you can use the narrower type SignerPartyId in modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2?

}): 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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider this check

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

reading the armored key

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bind partyId into session adata

adata: ${EddsaMPCv2Utils.MPS_DKG_KEYGEN_ROUND1_STATE}:${partyId}

you can see below these lines (line 666 rn) that signing already binds that extra context into adata so keygen can do they same for party.

encryptionVersion: 1,
});

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

// #region Round1Share
async createOfflineRound1Share(params: {
txRequest: TxRequest;
Expand Down
131 changes: 130 additions & 1 deletion modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add USER vs BACKUP test, i.e. partyId: BACKUP produces different session state than USER by adding a test that runs both, decrypts both sessions, and asserts they differ

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,135 @@ 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)),
} 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;
Expand Down
Loading