Skip to content

Commit bcbfbc7

Browse files
committed
feat(sdk-core): add externalSigner createOfflineRound1Share handler
Add the EdDSA MPCv2 offline round-1 handler for external signer flows. It stores encrypted carry-over state for the next signing round. - Generate the round-1 EdDSA MPCv2 signature share from a fresh DSG session. - Persist DSG session state and user message payload in the round-1 session. - Encrypt session and ephemeral GPG private key data with signing-context adata. - Cover SJCL encryption, v2 envelopes, payload shape, and transaction guards. Ticket: WCI-378
1 parent 60e1f07 commit bcbfbc7

2 files changed

Lines changed: 223 additions & 3 deletions

File tree

modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,21 @@ import {
3131
CustomEddsaMPCv2SigningRound2GeneratingFunction,
3232
CustomEddsaMPCv2SigningRound3GeneratingFunction,
3333
RequestType,
34+
SignatureShareRecord,
3435
SignatureShareType,
3536
TSSParams,
3637
TSSParamsForMessage,
3738
TSSParamsForMessageWithPrv,
3839
TSSParamsWithPrv,
3940
TxRequest,
41+
isV2Envelope,
4042
} from '../baseTypes';
4143
import { BaseEddsaUtils } from './base';
4244
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
4345

4446
export class EddsaMPCv2Utils extends BaseEddsaUtils {
45-
// TODO(WCI-378): call the MPS_DSG_SIGNING_ROUND1/2_STATE in createOfflineRoundShare handlers
46-
// private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
47-
// private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE';
47+
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
48+
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
4849

4950
/** @inheritdoc */
5051
async createKeychains(params: {
@@ -532,6 +533,71 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
532533
// #endregion
533534

534535
// #region external signer
536+
537+
async createOfflineRound1Share(params: {
538+
txRequest: TxRequest;
539+
prv: string;
540+
walletPassphrase: string;
541+
encryptedPrv?: string;
542+
}): Promise<{
543+
signatureShareRound1: SignatureShareRecord;
544+
userGpgPubKey: string;
545+
encryptedRound1Session: string;
546+
encryptedUserGpgPrvKey: string;
547+
}> {
548+
const { prv, walletPassphrase, txRequest, encryptedPrv } = params;
549+
const { signableHex, derivationPath } = this.getSignableHexAndDerivationPath(
550+
txRequest,
551+
'Unable to find transactions in txRequest'
552+
);
553+
const adata = `${signableHex}:${derivationPath}`;
554+
555+
const userKeyShare = Buffer.from(prv, 'base64');
556+
const userGpgKey = await generateGPGKeyPair('ed25519');
557+
const userGpgPrvKey = await pgp.readPrivateKey({ armoredKey: userGpgKey.privateKey });
558+
559+
const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
560+
userDsg.initDsg(userKeyShare, Buffer.from(signableHex, 'hex'), derivationPath, MPCv2PartiesEnum.BITGO);
561+
const userMsg1 = userDsg.getFirstMessage();
562+
const signatureShareRound1 = await getSignatureShareRoundOne(userMsg1, userGpgPrvKey);
563+
const sessionPayload = JSON.stringify({
564+
dsgSession: userDsg.getSession(),
565+
userMsgPayload: Buffer.from(userMsg1.payload).toString('base64'),
566+
});
567+
const userGpgPubKey = userGpgKey.publicKey;
568+
569+
const useV2 = encryptedPrv !== undefined && isV2Envelope(encryptedPrv);
570+
if (useV2) {
571+
const session = await this.bitgo.createEncryptionSession(walletPassphrase);
572+
try {
573+
const encryptedRound1Session = await session.encrypt(
574+
sessionPayload,
575+
`${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${adata}`
576+
);
577+
const encryptedUserGpgPrvKey = await session.encrypt(
578+
userGpgKey.privateKey,
579+
`${EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY}:${adata}`
580+
);
581+
return { signatureShareRound1, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey };
582+
} finally {
583+
session.destroy();
584+
}
585+
}
586+
587+
const encryptedRound1Session = this.bitgo.encrypt({
588+
input: sessionPayload,
589+
password: walletPassphrase,
590+
adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${adata}`,
591+
});
592+
const encryptedUserGpgPrvKey = this.bitgo.encrypt({
593+
input: userGpgKey.privateKey,
594+
password: walletPassphrase,
595+
adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY}:${adata}`,
596+
});
597+
598+
return { signatureShareRound1, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey };
599+
}
600+
535601
/** @inheritdoc */
536602
async signEddsaMPCv2TssUsingExternalSigner(
537603
params: TSSParams | TSSParamsForMessage,

modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as assert from 'assert';
22
import * as sinon from 'sinon';
33
import * as pgp from 'openpgp';
4+
import { randomBytes } from 'crypto';
45
import { EddsaMPSDsg, MPSComms, MPSUtil } from '@bitgo/sdk-lib-mpc';
6+
import * as sjcl from '@bitgo/sjcl';
57
import {
68
EddsaMPCv2SignatureShareRound1Input,
79
EddsaMPCv2SignatureShareRound1Output,
@@ -338,6 +340,158 @@ describe('EdDSA MPS DSG helper functions', async () => {
338340
});
339341
});
340342

343+
describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
344+
let eddsaMPCv2Utils: EddsaMPCv2Utils;
345+
let mockBitgo: BitGoBase;
346+
let userKeyShare: Buffer;
347+
348+
const walletPassphrase = 'testPass';
349+
const signableHex = 'deadbeef';
350+
const derivationPath = 'm/0/0';
351+
const expectedAdata = `${signableHex}:${derivationPath}`;
352+
const txRequest: TxRequest = {
353+
txRequestId: 'txreq-eddsa-round1',
354+
walletId: 'wallet-eddsa-round1',
355+
enterpriseId: 'enterprise-eddsa-round1',
356+
apiVersion: 'full',
357+
transactions: [
358+
{
359+
unsignedTx: {
360+
signableHex,
361+
derivationPath,
362+
serializedTxHex: signableHex,
363+
},
364+
signatureShares: [],
365+
},
366+
],
367+
intent: { intentType: 'payment' },
368+
unsignedTxs: [],
369+
} as unknown as TxRequest;
370+
371+
before('generate EdDSA user key share', async () => {
372+
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
373+
userKeyShare = userDkg.getKeyShare();
374+
});
375+
376+
beforeEach(() => {
377+
mockBitgo = {
378+
encrypt: sinon.stub().callsFake((params) => {
379+
const salt = randomBytes(8);
380+
const iv = randomBytes(16);
381+
return sjcl.encrypt(params.password, params.input, {
382+
salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4))],
383+
iv: [
384+
bytesToWord(iv.subarray(0, 4)),
385+
bytesToWord(iv.subarray(4, 8)),
386+
bytesToWord(iv.subarray(8, 12)),
387+
bytesToWord(iv.subarray(12, 16)),
388+
],
389+
adata: params.adata,
390+
});
391+
}),
392+
} as unknown as BitGoBase;
393+
394+
const mockCoin = {
395+
getMPCAlgorithm: sinon.stub().returns('eddsa'),
396+
} as unknown as IBaseCoin;
397+
398+
eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin);
399+
});
400+
401+
it('should create a round-1 share and encrypted SJCL session payload', async () => {
402+
const result = await eddsaMPCv2Utils.createOfflineRound1Share({
403+
txRequest,
404+
prv: userKeyShare.toString('base64'),
405+
walletPassphrase,
406+
});
407+
408+
assert.strictEqual(result.signatureShareRound1.from, SignatureShareType.USER);
409+
assert.strictEqual(result.signatureShareRound1.to, SignatureShareType.BITGO);
410+
assert.ok(result.userGpgPubKey.includes('BEGIN PGP PUBLIC KEY BLOCK'));
411+
assert.ok(JSON.parse(result.encryptedRound1Session).ct, 'encryptedRound1Session should be an SJCL JSON blob');
412+
assert.ok(JSON.parse(result.encryptedUserGpgPrvKey).ct, 'encryptedUserGpgPrvKey should be an SJCL JSON blob');
413+
414+
const parsedShare = decodeWithCodec(
415+
EddsaMPCv2SignatureShareRound1Input,
416+
JSON.parse(result.signatureShareRound1.share),
417+
'EddsaMPCv2SignatureShareRound1Input'
418+
);
419+
assert.strictEqual(parsedShare.type, 'round1Input');
420+
assert.ok(parsedShare.data.msg1.message, 'msg1.message should be set');
421+
assert.ok(parsedShare.data.msg1.signature, 'msg1.signature should be set');
422+
423+
const encryptedRound1Session = JSON.parse(result.encryptedRound1Session);
424+
const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey);
425+
assert.strictEqual(
426+
decodeURIComponent(encryptedRound1Session.adata),
427+
`MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`,
428+
'round-1 session adata should bind the signing context'
429+
);
430+
assert.strictEqual(
431+
decodeURIComponent(encryptedUserGpgPrvKey.adata),
432+
`MPS_DSG_SIGNING_USER_GPG_KEY:${expectedAdata}`,
433+
'GPG private key adata should bind the signing context'
434+
);
435+
436+
const sessionPayload = JSON.parse(sjcl.decrypt(walletPassphrase, result.encryptedRound1Session));
437+
assert.ok(sessionPayload.dsgSession, 'dsgSession should be persisted for round 2');
438+
assert.ok(sessionPayload.userMsgPayload, 'userMsgPayload should be persisted for round 2');
439+
});
440+
441+
it('should use v2 encryption when encryptedPrv is a v2 envelope', async () => {
442+
const encrypt = sinon
443+
.stub()
444+
.callsFake((input: string, adata: string) => Promise.resolve(JSON.stringify({ v: 2, input, adata })));
445+
const destroy = sinon.stub();
446+
const createEncryptionSession = sinon.stub().resolves({ encrypt, destroy });
447+
mockBitgo.createEncryptionSession = createEncryptionSession;
448+
449+
const result = await eddsaMPCv2Utils.createOfflineRound1Share({
450+
txRequest,
451+
prv: userKeyShare.toString('base64'),
452+
walletPassphrase,
453+
encryptedPrv: JSON.stringify({ v: 2 }),
454+
});
455+
456+
sinon.assert.calledOnce(createEncryptionSession);
457+
assert.strictEqual(createEncryptionSession.getCall(0).args[0], walletPassphrase);
458+
sinon.assert.notCalled(mockBitgo.encrypt as sinon.SinonStub);
459+
sinon.assert.calledTwice(encrypt);
460+
sinon.assert.calledOnce(destroy);
461+
462+
const encryptedRound1Session = JSON.parse(result.encryptedRound1Session);
463+
const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey);
464+
assert.strictEqual(encryptedRound1Session.v, 2);
465+
assert.strictEqual(encryptedRound1Session.adata, `MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`);
466+
assert.strictEqual(encryptedUserGpgPrvKey.v, 2);
467+
assert.strictEqual(encryptedUserGpgPrvKey.adata, `MPS_DSG_SIGNING_USER_GPG_KEY:${expectedAdata}`);
468+
469+
const sessionPayload = JSON.parse(encryptedRound1Session.input);
470+
assert.ok(sessionPayload.dsgSession, 'dsgSession should be persisted for round 2');
471+
assert.ok(sessionPayload.userMsgPayload, 'userMsgPayload should be persisted for round 2');
472+
});
473+
474+
it('should propagate the tx-only guard when transactions are missing', async () => {
475+
await assert.rejects(
476+
() =>
477+
eddsaMPCv2Utils.createOfflineRound1Share({
478+
txRequest: { ...txRequest, transactions: undefined } as unknown as TxRequest,
479+
prv: userKeyShare.toString('base64'),
480+
walletPassphrase,
481+
}),
482+
/Unable to find transactions in txRequest/
483+
);
484+
});
485+
});
486+
487+
function bytesToWord(bytes?: Uint8Array | number[]): number {
488+
if (!(bytes instanceof Uint8Array) || bytes.length !== 4) {
489+
throw new Error('bytes must be a Uint8Array with length 4');
490+
}
491+
492+
return bytes.reduce((num, byte) => num * 0x100 + byte, 0);
493+
}
494+
341495
describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => {
342496
let sandbox: sinon.SinonSandbox;
343497
let eddsaMPCv2Utils: EddsaMPCv2Utils;

0 commit comments

Comments
 (0)