Skip to content

Commit ce7b4ec

Browse files
authored
Merge pull request #9187 from BitGo/wci-889-add-createofflinekeygenround1share
feat(sdk-core): add createOfflineKeyGenRound1Share to EddsaMPCv2Utils
2 parents d3c7359 + 8d9aadf commit ce7b4ec

2 files changed

Lines changed: 225 additions & 1 deletion

File tree

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
5454
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
5555
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
5656
private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE';
57+
private static readonly MPS_DKG_KEYGEN_ROUND1_STATE = 'MPS_DKG_KEYGEN_ROUND1_STATE';
5758

5859
/** @inheritdoc */
5960
async createKeychains(params: {
@@ -648,6 +649,60 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
648649

649650
// #region external signer
650651

652+
// #region KeyGenRound1Share
653+
async createOfflineKeyGenRound1Share(params: {
654+
walletPassphrase: string;
655+
encryptedUserGpgPrvKey: string;
656+
bitgoGpgPubKey: string;
657+
counterPartyGpgPubKey: string;
658+
partyId?: MPCv2PartiesEnum.USER | MPCv2PartiesEnum.BACKUP;
659+
}): Promise<{
660+
signedMsg1: MPSTypes.MPSSignedMessage;
661+
encryptedRound1Session: string;
662+
}> {
663+
const { walletPassphrase, encryptedUserGpgPrvKey, bitgoGpgPubKey, counterPartyGpgPubKey } = params;
664+
const partyId = params.partyId ?? MPCv2PartiesEnum.USER;
665+
666+
const decryptedGpgPrvKey = isV2Envelope(encryptedUserGpgPrvKey)
667+
? await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase })
668+
: this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase });
669+
const gpgPrvKey = await pgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey });
670+
671+
const [, ownSk] = await MPSComms.extractEd25519KeyPair(gpgPrvKey);
672+
673+
if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
674+
assert(isBitgoEddsaMpcv2PubKey(bitgoGpgPubKey), 'Invalid BitGo GPG public key');
675+
}
676+
677+
const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoGpgPubKey });
678+
const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj);
679+
680+
const counterPartyKeyObj = await pgp.readKey({ armoredKey: counterPartyGpgPubKey });
681+
const counterPartyPk = await MPSComms.extractEd25519PublicKey(counterPartyKeyObj);
682+
683+
const dkg = new EddsaMPSDkg.DKG(3, 2, partyId);
684+
await dkg.initDkg(ownSk, [counterPartyPk, bitgoPk]);
685+
686+
const msg1 = dkg.getFirstMessage();
687+
const signedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(msg1.payload), gpgPrvKey);
688+
689+
const sessionPayload = JSON.stringify({
690+
dkgSession: dkg.getSession(),
691+
ownMsgPayload: Buffer.from(msg1.payload).toString('base64'),
692+
ownMsgFrom: msg1.from,
693+
});
694+
695+
const encryptedRound1Session = await this.bitgo.encryptAsync({
696+
input: sessionPayload,
697+
password: walletPassphrase,
698+
adata: `${EddsaMPCv2Utils.MPS_DKG_KEYGEN_ROUND1_STATE}:${partyId}`,
699+
encryptionVersion: 1,
700+
});
701+
702+
return { signedMsg1, encryptedRound1Session };
703+
}
704+
// #endregion
705+
651706
// #region Round1Share
652707
async createOfflineRound1Share(params: {
653708
txRequest: TxRequest;

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

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as assert from 'assert';
22
import * as sinon from 'sinon';
33
import * as pgp from 'openpgp';
44
import { randomBytes } from 'crypto';
5-
import { EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc';
5+
import { EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc';
66
import { ed25519 } from '@noble/curves/ed25519';
77
import * as sjcl from '@bitgo/sjcl';
88
import {
@@ -608,6 +608,175 @@ describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
608608
});
609609
});
610610

611+
describe('EddsaMPCv2Utils.createOfflineKeyGenRound1Share', () => {
612+
let eddsaMPCv2Utils: EddsaMPCv2Utils;
613+
let mockBitgo: BitGoBase;
614+
let userGpgKeyPair: pgp.SerializedKeyPair<string>;
615+
let backupGpgKeyPair: pgp.SerializedKeyPair<string>;
616+
let bitgoGpgKeyPair: pgp.SerializedKeyPair<string>;
617+
618+
const walletPassphrase = 'testPass';
619+
620+
before('generate GPG key pairs', async () => {
621+
userGpgKeyPair = await generateGPGKeyPair('ed25519');
622+
backupGpgKeyPair = await generateGPGKeyPair('ed25519');
623+
bitgoGpgKeyPair = await generateGPGKeyPair('ed25519');
624+
});
625+
626+
beforeEach(() => {
627+
const sjclEncrypt = (params: { password: string; input: string; adata?: string }) => {
628+
const salt = randomBytes(8);
629+
const iv = randomBytes(16);
630+
return sjcl.encrypt(params.password, params.input, {
631+
salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4))],
632+
iv: [
633+
bytesToWord(iv.subarray(0, 4)),
634+
bytesToWord(iv.subarray(4, 8)),
635+
bytesToWord(iv.subarray(8, 12)),
636+
bytesToWord(iv.subarray(12, 16)),
637+
],
638+
adata: params.adata,
639+
});
640+
};
641+
mockBitgo = {
642+
encrypt: sinon.stub().callsFake(sjclEncrypt),
643+
encryptAsync: sinon.stub().callsFake(async (params) => sjclEncrypt(params)),
644+
decrypt: sinon.stub().callsFake((params) => sjcl.decrypt(params.password, params.input)),
645+
decryptAsync: sinon.stub().callsFake(async (params) => sjcl.decrypt(params.password, params.input)),
646+
getEnv: sinon.stub().returns('mock'),
647+
} as unknown as BitGoBase;
648+
649+
const mockCoin = {
650+
getMPCAlgorithm: sinon.stub().returns('eddsa'),
651+
} as unknown as IBaseCoin;
652+
653+
eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin);
654+
});
655+
656+
it('should produce valid signedMsg1 and encrypted round-1 session', async () => {
657+
const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey);
658+
659+
const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
660+
walletPassphrase,
661+
encryptedUserGpgPrvKey,
662+
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
663+
counterPartyGpgPubKey: backupGpgKeyPair.publicKey,
664+
});
665+
666+
assert.ok(result.signedMsg1.message, 'signedMsg1.message should be set');
667+
assert.ok(
668+
result.signedMsg1.signature.includes('BEGIN PGP SIGNATURE'),
669+
'signedMsg1.signature should be PGP armored'
670+
);
671+
assert.ok(JSON.parse(result.encryptedRound1Session).ct, 'encryptedRound1Session should be an SJCL JSON blob');
672+
});
673+
674+
it('encryptedRound1Session should only be decryptable with correct walletPassphrase', async () => {
675+
const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey);
676+
677+
const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
678+
walletPassphrase,
679+
encryptedUserGpgPrvKey,
680+
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
681+
counterPartyGpgPubKey: backupGpgKeyPair.publicKey,
682+
});
683+
684+
const decrypted = sjcl.decrypt(walletPassphrase, result.encryptedRound1Session);
685+
const session = JSON.parse(decrypted);
686+
assert.ok(session.dkgSession, 'dkgSession should be persisted');
687+
assert.ok(session.ownMsgPayload, 'ownMsgPayload should be persisted');
688+
assert.strictEqual(typeof session.ownMsgFrom, 'number', 'ownMsgFrom should be a number');
689+
690+
assert.throws(
691+
() => sjcl.decrypt('wrongpassphrase', result.encryptedRound1Session),
692+
'should throw on wrong passphrase'
693+
);
694+
});
695+
696+
it('should restore DKG session and handle round-2 messages after round 1', async () => {
697+
const encryptedUserGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey);
698+
699+
const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
700+
walletPassphrase,
701+
encryptedUserGpgPrvKey,
702+
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
703+
counterPartyGpgPubKey: backupGpgKeyPair.publicKey,
704+
});
705+
706+
const decrypted = sjcl.decrypt(walletPassphrase, result.encryptedRound1Session);
707+
const { dkgSession, ownMsgPayload, ownMsgFrom } = JSON.parse(decrypted) as {
708+
dkgSession: string;
709+
ownMsgPayload: string;
710+
ownMsgFrom: number;
711+
};
712+
713+
const restoredDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER);
714+
await restoredDkg.restoreSession(dkgSession);
715+
716+
const ownMsg1: MPSTypes.DeserializedMessage = {
717+
from: ownMsgFrom,
718+
payload: new Uint8Array(Buffer.from(ownMsgPayload, 'base64')),
719+
};
720+
assert.ok(ownMsg1.payload.length > 0, 'restored payload should be non-empty');
721+
722+
const userGpgKey = await pgp.readKey({ armoredKey: userGpgKeyPair.publicKey });
723+
const rawBytes = await MPSComms.verifyMpsMessage(result.signedMsg1, userGpgKey);
724+
assert.ok(rawBytes.length > 0, 'signedMsg1 should verify against user GPG key');
725+
});
726+
727+
it('should reject invalid encryptedUserGpgPrvKey', async () => {
728+
await assert.rejects(
729+
() =>
730+
eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
731+
walletPassphrase,
732+
encryptedUserGpgPrvKey: sjcl.encrypt(walletPassphrase, 'not-a-gpg-key'),
733+
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
734+
counterPartyGpgPubKey: backupGpgKeyPair.publicKey,
735+
}),
736+
'should throw when GPG key cannot be parsed'
737+
);
738+
});
739+
740+
it('USER and BACKUP partyId produce different encrypted session state', async () => {
741+
const userEncryptedGpgPrvKey = sjcl.encrypt(walletPassphrase, userGpgKeyPair.privateKey);
742+
743+
const userResult = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
744+
walletPassphrase,
745+
encryptedUserGpgPrvKey: userEncryptedGpgPrvKey,
746+
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
747+
counterPartyGpgPubKey: backupGpgKeyPair.publicKey,
748+
partyId: MPCv2PartiesEnum.USER,
749+
});
750+
751+
const backupEncryptedGpgPrvKey2 = sjcl.encrypt(walletPassphrase, backupGpgKeyPair.privateKey);
752+
const backupResult = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({
753+
walletPassphrase,
754+
encryptedUserGpgPrvKey: backupEncryptedGpgPrvKey2,
755+
bitgoGpgPubKey: bitgoGpgKeyPair.publicKey,
756+
counterPartyGpgPubKey: userGpgKeyPair.publicKey,
757+
partyId: MPCv2PartiesEnum.BACKUP,
758+
});
759+
760+
const userSession = JSON.parse(sjcl.decrypt(walletPassphrase, userResult.encryptedRound1Session));
761+
const backupSession = JSON.parse(sjcl.decrypt(walletPassphrase, backupResult.encryptedRound1Session));
762+
763+
assert.strictEqual(userSession.ownMsgFrom, MPCv2PartiesEnum.USER);
764+
assert.strictEqual(backupSession.ownMsgFrom, MPCv2PartiesEnum.BACKUP);
765+
assert.notStrictEqual(userSession.dkgSession, backupSession.dkgSession, 'Sessions should differ between parties');
766+
767+
// Sessions encrypted with party-bound adata — tampered adata fails CCM tag check
768+
const tamperedSession = (() => {
769+
const parsed = JSON.parse(userResult.encryptedRound1Session);
770+
parsed.adata = encodeURIComponent(`MPS_DKG_KEYGEN_ROUND1_STATE:${MPCv2PartiesEnum.BACKUP}`);
771+
return JSON.stringify(parsed);
772+
})();
773+
assert.throws(
774+
() => sjcl.decrypt(walletPassphrase, tamperedSession),
775+
'should fail to decrypt with wrong party adata'
776+
);
777+
});
778+
});
779+
611780
describe('EddsaMPCv2Utils.createOfflineRound2Share', () => {
612781
let eddsaMPCv2Utils: EddsaMPCv2Utils;
613782
let mockBitgo: BitGoBase;

0 commit comments

Comments
 (0)