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
72 changes: 69 additions & 3 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,21 @@ import {
CustomEddsaMPCv2SigningRound2GeneratingFunction,
CustomEddsaMPCv2SigningRound3GeneratingFunction,
RequestType,
SignatureShareRecord,
SignatureShareType,
TSSParams,
TSSParamsForMessage,
TSSParamsForMessageWithPrv,
TSSParamsWithPrv,
TxRequest,
isV2Envelope,
} from '../baseTypes';
import { BaseEddsaUtils } from './base';
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';

export class EddsaMPCv2Utils extends BaseEddsaUtils {
// TODO(WCI-378): call the MPS_DSG_SIGNING_ROUND1/2_STATE in createOfflineRoundShare handlers
// 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_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';

/** @inheritdoc */
async createKeychains(params: {
Expand Down Expand Up @@ -532,6 +533,71 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
// #endregion

// #region external signer

async createOfflineRound1Share(params: {
txRequest: TxRequest;
prv: string;
walletPassphrase: string;
encryptedPrv?: string;
}): Promise<{
signatureShareRound1: SignatureShareRecord;
userGpgPubKey: string;
encryptedRound1Session: string;
encryptedUserGpgPrvKey: string;
}> {
const { prv, walletPassphrase, txRequest, encryptedPrv } = params;
const { signableHex, derivationPath } = this.getSignableHexAndDerivationPath(
txRequest,
'Unable to find transactions in txRequest'
);
const adata = `${signableHex}:${derivationPath}`;

const userKeyShare = Buffer.from(prv, 'base64');
const userGpgKey = await generateGPGKeyPair('ed25519');
const userGpgPrvKey = await pgp.readPrivateKey({ armoredKey: userGpgKey.privateKey });

const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
userDsg.initDsg(userKeyShare, Buffer.from(signableHex, 'hex'), derivationPath, MPCv2PartiesEnum.BITGO);
const userMsg1 = userDsg.getFirstMessage();
const signatureShareRound1 = await getSignatureShareRoundOne(userMsg1, userGpgPrvKey);
const sessionPayload = JSON.stringify({
dsgSession: userDsg.getSession(),
userMsgPayload: Buffer.from(userMsg1.payload).toString('base64'),
});
const userGpgPubKey = userGpgKey.publicKey;

const useV2 = encryptedPrv !== undefined && isV2Envelope(encryptedPrv);
if (useV2) {
const session = await this.bitgo.createEncryptionSession(walletPassphrase);
try {
const encryptedRound1Session = await session.encrypt(
sessionPayload,
`${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${adata}`
);
const encryptedUserGpgPrvKey = await session.encrypt(
userGpgKey.privateKey,
`${EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY}:${adata}`
);
return { signatureShareRound1, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey };
} finally {
session.destroy();
}
}

const encryptedRound1Session = this.bitgo.encrypt({
input: sessionPayload,
password: walletPassphrase,
adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${adata}`,
});
const encryptedUserGpgPrvKey = this.bitgo.encrypt({
input: userGpgKey.privateKey,
password: walletPassphrase,
adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY}:${adata}`,
});

return { signatureShareRound1, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey };
}

/** @inheritdoc */
async signEddsaMPCv2TssUsingExternalSigner(
params: TSSParams | TSSParamsForMessage,
Expand Down
154 changes: 154 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import * as pgp from 'openpgp';
import { randomBytes } from 'crypto';
import { EddsaMPSDsg, MPSComms, MPSUtil } from '@bitgo/sdk-lib-mpc';
import * as sjcl from '@bitgo/sjcl';
import {
EddsaMPCv2SignatureShareRound1Input,
EddsaMPCv2SignatureShareRound1Output,
Expand Down Expand Up @@ -338,6 +340,158 @@ describe('EdDSA MPS DSG helper functions', async () => {
});
});

describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
let eddsaMPCv2Utils: EddsaMPCv2Utils;
let mockBitgo: BitGoBase;
let userKeyShare: Buffer;

const walletPassphrase = 'testPass';
const signableHex = 'deadbeef';
const derivationPath = 'm/0/0';
const expectedAdata = `${signableHex}:${derivationPath}`;
const txRequest: TxRequest = {
txRequestId: 'txreq-eddsa-round1',
walletId: 'wallet-eddsa-round1',
enterpriseId: 'enterprise-eddsa-round1',
apiVersion: 'full',
transactions: [
{
unsignedTx: {
signableHex,
derivationPath,
serializedTxHex: signableHex,
},
signatureShares: [],
},
],
intent: { intentType: 'payment' },
unsignedTxs: [],
} as unknown as TxRequest;

before('generate EdDSA user key share', async () => {
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
userKeyShare = userDkg.getKeyShare();
});

beforeEach(() => {
mockBitgo = {
encrypt: sinon.stub().callsFake((params) => {
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,
});
}),
} as unknown as BitGoBase;

const mockCoin = {
getMPCAlgorithm: sinon.stub().returns('eddsa'),
} as unknown as IBaseCoin;

eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin);
});

it('should create a round-1 share and encrypted SJCL session payload', async () => {
const result = await eddsaMPCv2Utils.createOfflineRound1Share({
txRequest,
prv: userKeyShare.toString('base64'),
walletPassphrase,
});

assert.strictEqual(result.signatureShareRound1.from, SignatureShareType.USER);
assert.strictEqual(result.signatureShareRound1.to, SignatureShareType.BITGO);
assert.ok(result.userGpgPubKey.includes('BEGIN PGP PUBLIC KEY BLOCK'));
assert.ok(JSON.parse(result.encryptedRound1Session).ct, 'encryptedRound1Session should be an SJCL JSON blob');
assert.ok(JSON.parse(result.encryptedUserGpgPrvKey).ct, 'encryptedUserGpgPrvKey should be an SJCL JSON blob');

const parsedShare = decodeWithCodec(
EddsaMPCv2SignatureShareRound1Input,
JSON.parse(result.signatureShareRound1.share),
'EddsaMPCv2SignatureShareRound1Input'
);
assert.strictEqual(parsedShare.type, 'round1Input');
assert.ok(parsedShare.data.msg1.message, 'msg1.message should be set');
assert.ok(parsedShare.data.msg1.signature, 'msg1.signature should be set');

const encryptedRound1Session = JSON.parse(result.encryptedRound1Session);
const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey);
assert.strictEqual(
decodeURIComponent(encryptedRound1Session.adata),
`MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`,
'round-1 session adata should bind the signing context'
);
assert.strictEqual(
decodeURIComponent(encryptedUserGpgPrvKey.adata),
`MPS_DSG_SIGNING_USER_GPG_KEY:${expectedAdata}`,
'GPG private key adata should bind the signing context'
);

const sessionPayload = JSON.parse(sjcl.decrypt(walletPassphrase, result.encryptedRound1Session));
assert.ok(sessionPayload.dsgSession, 'dsgSession should be persisted for round 2');
assert.ok(sessionPayload.userMsgPayload, 'userMsgPayload should be persisted for round 2');
});

it('should use v2 encryption when encryptedPrv is a v2 envelope', async () => {
const encrypt = sinon
.stub()
.callsFake((input: string, adata: string) => Promise.resolve(JSON.stringify({ v: 2, input, adata })));
const destroy = sinon.stub();
const createEncryptionSession = sinon.stub().resolves({ encrypt, destroy });
mockBitgo.createEncryptionSession = createEncryptionSession;

const result = await eddsaMPCv2Utils.createOfflineRound1Share({
txRequest,
prv: userKeyShare.toString('base64'),
walletPassphrase,
encryptedPrv: JSON.stringify({ v: 2 }),
});

sinon.assert.calledOnce(createEncryptionSession);
assert.strictEqual(createEncryptionSession.getCall(0).args[0], walletPassphrase);
sinon.assert.notCalled(mockBitgo.encrypt as sinon.SinonStub);
sinon.assert.calledTwice(encrypt);
sinon.assert.calledOnce(destroy);

const encryptedRound1Session = JSON.parse(result.encryptedRound1Session);
const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey);
assert.strictEqual(encryptedRound1Session.v, 2);
assert.strictEqual(encryptedRound1Session.adata, `MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`);
assert.strictEqual(encryptedUserGpgPrvKey.v, 2);
assert.strictEqual(encryptedUserGpgPrvKey.adata, `MPS_DSG_SIGNING_USER_GPG_KEY:${expectedAdata}`);

const sessionPayload = JSON.parse(encryptedRound1Session.input);
assert.ok(sessionPayload.dsgSession, 'dsgSession should be persisted for round 2');
assert.ok(sessionPayload.userMsgPayload, 'userMsgPayload should be persisted for round 2');
});

it('should propagate the tx-only guard when transactions are missing', async () => {
await assert.rejects(
() =>
eddsaMPCv2Utils.createOfflineRound1Share({
txRequest: { ...txRequest, transactions: undefined } as unknown as TxRequest,
prv: userKeyShare.toString('base64'),
walletPassphrase,
}),
/Unable to find transactions in txRequest/
);
});
});

function bytesToWord(bytes?: Uint8Array | number[]): number {
if (!(bytes instanceof Uint8Array) || bytes.length !== 4) {
throw new Error('bytes must be a Uint8Array with length 4');
}

return bytes.reduce((num, byte) => num * 0x100 + byte, 0);
}

describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => {
let sandbox: sinon.SinonSandbox;
let eddsaMPCv2Utils: EddsaMPCv2Utils;
Expand Down
Loading