@@ -298,6 +298,253 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
298298 } ) ;
299299 } ) ;
300300
301+ describe ( 'External Signing Helpers' , function ( ) {
302+ let userGpgKeyPair : openpgp . SerializedKeyPair < string > & { revocationCertificate : string } ;
303+
304+ before ( async function ( ) {
305+ openpgp . config . rejectCurves = new Set ( ) ;
306+ userGpgKeyPair = await openpgp . generateKey ( {
307+ userIDs : [ { name : 'user' , email : 'user@test.com' } ] ,
308+ curve : 'ed25519' ,
309+ format : 'armored' ,
310+ } ) ;
311+ } ) ;
312+
313+ describe ( 'getSignableHexAndDerivationPath' , function ( ) {
314+ it ( 'should extract signableHex and derivationPath from a valid txRequest' , function ( ) {
315+ const txRequest = {
316+ transactions : [
317+ {
318+ unsignedTx : {
319+ signableHex : 'deadbeef' ,
320+ derivationPath : 'm/0/0' ,
321+ serializedTxHex : 'aabbccdd' ,
322+ } ,
323+ } ,
324+ ] ,
325+ } ;
326+
327+ const result = ( tssUtils as any ) . getSignableHexAndDerivationPath ( txRequest ) ;
328+ assert . equal ( result . signableHex , 'deadbeef' ) ;
329+ assert . equal ( result . derivationPath , 'm/0/0' ) ;
330+ } ) ;
331+
332+ it ( 'should throw when transactions field is missing' , function ( ) {
333+ const txRequest = { messages : [ { messageEncoded : 'test' } ] } ;
334+
335+ assert . throws (
336+ ( ) => ( tssUtils as any ) . getSignableHexAndDerivationPath ( txRequest ) ,
337+ / c r e a t e O f f l i n e S h a r e r e q u i r e s e x a c t l y o n e t r a n s a c t i o n i n t x R e q u e s t /
338+ ) ;
339+ } ) ;
340+
341+ it ( 'should throw when transactions array is empty' , function ( ) {
342+ const txRequest = { transactions : [ ] } ;
343+
344+ assert . throws (
345+ ( ) => ( tssUtils as any ) . getSignableHexAndDerivationPath ( txRequest ) ,
346+ / c r e a t e O f f l i n e S h a r e r e q u i r e s e x a c t l y o n e t r a n s a c t i o n i n t x R e q u e s t /
347+ ) ;
348+ } ) ;
349+
350+ it ( 'should throw when transactions array has more than one element' , function ( ) {
351+ const txRequest = {
352+ transactions : [
353+ { unsignedTx : { signableHex : 'aaa' , derivationPath : 'm/0' } } ,
354+ { unsignedTx : { signableHex : 'bbb' , derivationPath : 'm/1' } } ,
355+ ] ,
356+ } ;
357+
358+ assert . throws (
359+ ( ) => ( tssUtils as any ) . getSignableHexAndDerivationPath ( txRequest ) ,
360+ / c r e a t e O f f l i n e S h a r e r e q u i r e s e x a c t l y o n e t r a n s a c t i o n i n t x R e q u e s t /
361+ ) ;
362+ } ) ;
363+
364+ it ( 'should throw when signableHex is missing' , function ( ) {
365+ const txRequest = { transactions : [ { unsignedTx : { derivationPath : 'm/0' } } ] } ;
366+
367+ assert . throws (
368+ ( ) => ( tssUtils as any ) . getSignableHexAndDerivationPath ( txRequest ) ,
369+ / M i s s i n g s i g n a b l e H e x i n u n s i g n e d T x /
370+ ) ;
371+ } ) ;
372+
373+ it ( 'should throw when derivationPath is missing' , function ( ) {
374+ const txRequest = { transactions : [ { unsignedTx : { signableHex : 'deadbeef' } } ] } ;
375+
376+ assert . throws (
377+ ( ) => ( tssUtils as any ) . getSignableHexAndDerivationPath ( txRequest ) ,
378+ / M i s s i n g d e r i v a t i o n P a t h i n u n s i g n e d T x /
379+ ) ;
380+ } ) ;
381+ } ) ;
382+
383+ describe ( 'getBitgoAndUserGpgKeys' , function ( ) {
384+ it ( 'should decrypt v1 SJCL envelope and return GPG keys' , async function ( ) {
385+ const passphrase = 'test-password' ;
386+ const adata = 'test-adata' ;
387+
388+ // Encrypt user GPG private key with v1 SJCL (no adata for simplicity in v1)
389+ const encryptedUserGpgPrvKey = bitgo . encrypt ( {
390+ input : userGpgKeyPair . privateKey ,
391+ password : passphrase ,
392+ } ) ;
393+
394+ const result = await ( tssUtils as any ) . getBitgoAndUserGpgKeys (
395+ bitgoGpgKeyPair . publicKey ,
396+ encryptedUserGpgPrvKey ,
397+ passphrase ,
398+ adata
399+ ) ;
400+
401+ assert . ok ( result . bitgoGpgKey ) ;
402+ assert . ok ( result . userGpgPrvKey ) ;
403+ assert . ok ( result . userGpgPrvKey . constructor . name === 'PrivateKey' ) ;
404+ } ) ;
405+
406+ it ( 'should decrypt v2 Argon2 envelope and return GPG keys' , async function ( ) {
407+ this . timeout ( 10000 ) ; // v2 decryption with Argon2 can be slow
408+
409+ const passphrase = 'test-password' ;
410+ const adata = 'test-adata' ;
411+ const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY' ;
412+
413+ // Encrypt user GPG private key with v2 Argon2
414+ const encryptedUserGpgPrvKey = await bitgo . encryptAsync ( {
415+ input : userGpgKeyPair . privateKey ,
416+ password : passphrase ,
417+ adata : `${ domainSeparator } :${ adata } ` ,
418+ } ) ;
419+
420+ const result = await ( tssUtils as any ) . getBitgoAndUserGpgKeys (
421+ bitgoGpgKeyPair . publicKey ,
422+ encryptedUserGpgPrvKey ,
423+ passphrase ,
424+ adata
425+ ) ;
426+
427+ assert . ok ( result . bitgoGpgKey ) ;
428+ assert . ok ( result . userGpgPrvKey ) ;
429+ assert . ok ( result . userGpgPrvKey . constructor . name === 'PrivateKey' ) ;
430+ } ) ;
431+
432+ it ( 'should throw when adata does not match (domain-separated format)' , async function ( ) {
433+ const passphrase = 'test-password' ;
434+ const correctAdata = 'correct-adata' ;
435+ const wrongAdata = 'wrong-adata' ;
436+ const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY' ;
437+
438+ // Encrypt with correct adata
439+ const encryptedUserGpgPrvKey = bitgo . encrypt ( {
440+ input : userGpgKeyPair . privateKey ,
441+ password : passphrase ,
442+ adata : `${ domainSeparator } :${ correctAdata } ` ,
443+ } ) ;
444+
445+ // Try to decrypt with wrong adata
446+ await assert . rejects (
447+ ( tssUtils as any ) . getBitgoAndUserGpgKeys (
448+ bitgoGpgKeyPair . publicKey ,
449+ encryptedUserGpgPrvKey ,
450+ passphrase ,
451+ wrongAdata
452+ ) ,
453+ / A d a t a d o e s n o t m a t c h c y p h e r t e x t a d a t a /
454+ ) ;
455+ } ) ;
456+
457+ it ( 'should throw when adata does not match (non-domain-separated format)' , async function ( ) {
458+ const passphrase = 'test-password' ;
459+ const correctAdata = 'correct-adata' ;
460+ const wrongAdata = 'wrong-adata' ;
461+
462+ // Encrypt with correct adata (no domain separator)
463+ const encryptedUserGpgPrvKey = bitgo . encrypt ( {
464+ input : userGpgKeyPair . privateKey ,
465+ password : passphrase ,
466+ adata : correctAdata ,
467+ } ) ;
468+
469+ // Try to decrypt with wrong adata
470+ await assert . rejects (
471+ ( tssUtils as any ) . getBitgoAndUserGpgKeys (
472+ bitgoGpgKeyPair . publicKey ,
473+ encryptedUserGpgPrvKey ,
474+ passphrase ,
475+ wrongAdata
476+ ) ,
477+ / A d a t a d o e s n o t m a t c h c y p h e r t e x t a d a t a /
478+ ) ;
479+ } ) ;
480+
481+ it ( 'should throw when cyphertext is not valid JSON' , async function ( ) {
482+ const passphrase = 'test-password' ;
483+ const adata = 'test-adata' ;
484+ const invalidCyphertext = 'not-valid-json' ;
485+
486+ await assert . rejects (
487+ ( tssUtils as any ) . getBitgoAndUserGpgKeys ( bitgoGpgKeyPair . publicKey , invalidCyphertext , passphrase , adata ) ,
488+ / F a i l e d t o p a r s e c y p h e r t e x t t o J S O N /
489+ ) ;
490+ } ) ;
491+ } ) ;
492+
493+ describe ( 'validateAdata' , function ( ) {
494+ it ( 'should pass when adata matches with domain separator' , function ( ) {
495+ const adata = 'test-value' ;
496+ const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE' ;
497+ const cyphertext = bitgo . encrypt ( {
498+ input : 'secret' ,
499+ password : 'password' ,
500+ adata : `${ domainSeparator } :${ adata } ` ,
501+ } ) ;
502+
503+ assert . doesNotThrow ( ( ) => {
504+ ( tssUtils as any ) . validateAdata ( adata , cyphertext , domainSeparator ) ;
505+ } ) ;
506+ } ) ;
507+
508+ it ( 'should pass when adata matches without domain separator' , function ( ) {
509+ const adata = 'test-value' ;
510+ const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE' ;
511+ const cyphertext = bitgo . encrypt ( {
512+ input : 'secret' ,
513+ password : 'password' ,
514+ adata : adata ,
515+ } ) ;
516+
517+ assert . doesNotThrow ( ( ) => {
518+ ( tssUtils as any ) . validateAdata ( adata , cyphertext , domainSeparator ) ;
519+ } ) ;
520+ } ) ;
521+
522+ it ( 'should throw when adata does not match' , function ( ) {
523+ const correctAdata = 'correct-value' ;
524+ const wrongAdata = 'wrong-value' ;
525+ const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE' ;
526+ const cyphertext = bitgo . encrypt ( {
527+ input : 'secret' ,
528+ password : 'password' ,
529+ adata : `${ domainSeparator } :${ correctAdata } ` ,
530+ } ) ;
531+
532+ assert . throws (
533+ ( ) => ( tssUtils as any ) . validateAdata ( wrongAdata , cyphertext , domainSeparator ) ,
534+ / A d a t a d o e s n o t m a t c h c y p h e r t e x t a d a t a /
535+ ) ;
536+ } ) ;
537+
538+ it ( 'should throw when cyphertext is not valid JSON' , function ( ) {
539+ const invalidCyphertext = 'not-json' ;
540+ assert . throws (
541+ ( ) => ( tssUtils as any ) . validateAdata ( 'adata' , invalidCyphertext , 'separator' ) ,
542+ / F a i l e d t o p a r s e c y p h e r t e x t t o J S O N /
543+ ) ;
544+ } ) ;
545+ } ) ;
546+ } ) ;
547+
301548 // ---------------------------------------------------------------------------
302549 // Nock helpers
303550 // ---------------------------------------------------------------------------
0 commit comments