Skip to content

Commit 59d77bc

Browse files
committed
feat(sdk-core): add createOfflineKeyGenRound1Share to EddsaMPCv2Utils
Add createOfflineKeyGenRound1Share to EddsaMPCv2Utils to support the external signer pattern for EdDSA MPCv2 DKG. This method decrypts the caller-provided GPG private key, initializes a DKG session for the given party, produces and signs the round-1 message, then serializes and encrypts the session state so round 2 can resume it later without re-running round 1. Pattern mirrors createOfflineRound1Share (signing) but targets EddsaMPSDkg.DKG instead of EddsaMPSDsg.DSG. The encryptedRound1Session payload includes dkgSession, ownMsgPayload (base64), and ownMsgFrom so that handleIncomingMessages can reconstruct the round-1 DeserializedMessage in round 2. Tests verify: valid signedMsg1 + encrypted session, passphrase binding, session restorability + message verification, and rejection of invalid GPG key material. Ticket: WCI-889 Session-Id: eac7d223-cc21-440f-83a1-6de6cd3cb3b6 Task-Id: 03326a98-89d2-41c6-8fab-6c6040541092
1 parent 505db21 commit 59d77bc

2 files changed

Lines changed: 227 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
@@ -53,6 +53,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
5353
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
5454
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
5555
private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE';
56+
private static readonly MPS_DKG_KEYGEN_ROUND1_STATE = 'MPS_DKG_KEYGEN_ROUND1_STATE';
5657

5758
/** @inheritdoc */
5859
async createKeychains(params: {
@@ -558,6 +559,60 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
558559

559560
// #region external signer
560561

562+
// #region KeyGenRound1Share
563+
async createOfflineKeyGenRound1Share(params: {
564+
walletPassphrase: string;
565+
encryptedUserGpgPrvKey: string;
566+
bitgoGpgPubKey: string;
567+
counterPartyGpgPubKey: string;
568+
partyId?: MPCv2PartiesEnum.USER | MPCv2PartiesEnum.BACKUP;
569+
}): Promise<{
570+
signedMsg1: MPSTypes.MPSSignedMessage;
571+
encryptedRound1Session: string;
572+
}> {
573+
const { walletPassphrase, encryptedUserGpgPrvKey, bitgoGpgPubKey, counterPartyGpgPubKey } = params;
574+
const partyId = params.partyId ?? MPCv2PartiesEnum.USER;
575+
576+
const decryptedGpgPrvKey = isV2Envelope(encryptedUserGpgPrvKey)
577+
? await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase })
578+
: this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase });
579+
const gpgPrvKey = await pgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey });
580+
581+
const [, ownSk] = await MPSComms.extractEd25519KeyPair(gpgPrvKey);
582+
583+
if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
584+
assert(isBitgoEddsaMpcv2PubKey(bitgoGpgPubKey), 'Invalid BitGo GPG public key');
585+
}
586+
587+
const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoGpgPubKey });
588+
const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj);
589+
590+
const counterPartyKeyObj = await pgp.readKey({ armoredKey: counterPartyGpgPubKey });
591+
const counterPartyPk = await MPSComms.extractEd25519PublicKey(counterPartyKeyObj);
592+
593+
const dkg = new EddsaMPSDkg.DKG(3, 2, partyId);
594+
await dkg.initDkg(ownSk, [counterPartyPk, bitgoPk]);
595+
596+
const msg1 = dkg.getFirstMessage();
597+
const signedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(msg1.payload), gpgPrvKey);
598+
599+
const sessionPayload = JSON.stringify({
600+
dkgSession: dkg.getSession(),
601+
ownMsgPayload: Buffer.from(msg1.payload).toString('base64'),
602+
ownMsgFrom: msg1.from,
603+
});
604+
605+
const encryptedRound1Session = await this.bitgo.encryptAsync({
606+
input: sessionPayload,
607+
password: walletPassphrase,
608+
adata: `${EddsaMPCv2Utils.MPS_DKG_KEYGEN_ROUND1_STATE}:${partyId}`,
609+
encryptionVersion: 1,
610+
});
611+
612+
return { signedMsg1, encryptedRound1Session };
613+
}
614+
// #endregion
615+
561616
// #region Round1Share
562617
async createOfflineRound1Share(params: {
563618
txRequest: TxRequest;

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

Lines changed: 172 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,177 @@ 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 different adata (party-bound) — wrong party cannot decrypt the other's session
768+
assert.throws(
769+
() =>
770+
sjcl.decrypt(
771+
walletPassphrase,
772+
userResult.encryptedRound1Session.replace(
773+
'"adata":"MPS_DKG_KEYGEN_ROUND1_STATE:0"',
774+
'"adata":"MPS_DKG_KEYGEN_ROUND1_STATE:1"'
775+
)
776+
),
777+
'should fail to decrypt with wrong party adata'
778+
);
779+
});
780+
});
781+
611782
describe('EddsaMPCv2Utils.createOfflineRound2Share', () => {
612783
let eddsaMPCv2Utils: EddsaMPCv2Utils;
613784
let mockBitgo: BitGoBase;

0 commit comments

Comments
 (0)