@@ -2,7 +2,7 @@ import * as assert from 'assert';
22import * as sinon from 'sinon' ;
33import * as pgp from 'openpgp' ;
44import { 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' ;
66import { ed25519 } from '@noble/curves/ed25519' ;
77import * as sjcl from '@bitgo/sjcl' ;
88import {
@@ -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+
611782describe ( 'EddsaMPCv2Utils.createOfflineRound2Share' , ( ) => {
612783 let eddsaMPCv2Utils : EddsaMPCv2Utils ;
613784 let mockBitgo : BitGoBase ;
0 commit comments