@@ -298,6 +298,275 @@ 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 without adata and skip validation' , async function ( ) {
385+ const passphrase = 'test-password' ;
386+
387+ // Ciphertext has no adata — the caller signals "skip validation" by passing ''.
388+ const encryptedUserGpgPrvKey = bitgo . encrypt ( {
389+ input : userGpgKeyPair . privateKey ,
390+ password : passphrase ,
391+ } ) ;
392+
393+ const result = await ( tssUtils as any ) . getBitgoAndUserGpgKeys (
394+ bitgoGpgKeyPair . publicKey ,
395+ encryptedUserGpgPrvKey ,
396+ passphrase ,
397+ '' // empty string → if (adata) guard in getBitgoAndUserGpgKeys skips validateAdata
398+ ) ;
399+
400+ assert . ok ( result . bitgoGpgKey ) ;
401+ assert . equal ( result . userGpgPrvKey . isPrivate ( ) , true ) ;
402+ } ) ;
403+
404+ it ( 'should decrypt v1 SJCL envelope with matching adata and pass validation' , async function ( ) {
405+ const passphrase = 'test-password' ;
406+ const adata = 'test-adata' ;
407+ const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY' ;
408+
409+ // Ciphertext is bound to the domain-separated adata — validateAdata must accept it.
410+ const encryptedUserGpgPrvKey = bitgo . encrypt ( {
411+ input : userGpgKeyPair . privateKey ,
412+ password : passphrase ,
413+ adata : `${ domainSeparator } :${ adata } ` ,
414+ } ) ;
415+
416+ const result = await ( tssUtils as any ) . getBitgoAndUserGpgKeys (
417+ bitgoGpgKeyPair . publicKey ,
418+ encryptedUserGpgPrvKey ,
419+ passphrase ,
420+ adata
421+ ) ;
422+
423+ assert . ok ( result . bitgoGpgKey ) ;
424+ assert . equal ( result . userGpgPrvKey . isPrivate ( ) , true ) ;
425+ } ) ;
426+
427+ it ( 'should decrypt v2 Argon2 envelope and return GPG keys' , async function ( ) {
428+ this . timeout ( 10000 ) ; // v2 decryption with Argon2 can be slow
429+
430+ const passphrase = 'test-password' ;
431+ const adata = 'test-adata' ;
432+ const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY' ;
433+
434+ // Encrypt user GPG private key with v2 Argon2
435+ const encryptedUserGpgPrvKey = await bitgo . encryptAsync ( {
436+ input : userGpgKeyPair . privateKey ,
437+ password : passphrase ,
438+ adata : `${ domainSeparator } :${ adata } ` ,
439+ } ) ;
440+
441+ const result = await ( tssUtils as any ) . getBitgoAndUserGpgKeys (
442+ bitgoGpgKeyPair . publicKey ,
443+ encryptedUserGpgPrvKey ,
444+ passphrase ,
445+ adata
446+ ) ;
447+
448+ assert . ok ( result . bitgoGpgKey ) ;
449+ assert . equal ( result . userGpgPrvKey . isPrivate ( ) , true ) ;
450+ } ) ;
451+
452+ it ( 'should throw when adata does not match (domain-separated format)' , async function ( ) {
453+ const passphrase = 'test-password' ;
454+ const correctAdata = 'correct-adata' ;
455+ const wrongAdata = 'wrong-adata' ;
456+ const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY' ;
457+
458+ // Encrypt with correct adata
459+ const encryptedUserGpgPrvKey = bitgo . encrypt ( {
460+ input : userGpgKeyPair . privateKey ,
461+ password : passphrase ,
462+ adata : `${ domainSeparator } :${ correctAdata } ` ,
463+ } ) ;
464+
465+ // Try to decrypt with wrong adata
466+ await assert . rejects (
467+ ( tssUtils as any ) . getBitgoAndUserGpgKeys (
468+ bitgoGpgKeyPair . publicKey ,
469+ encryptedUserGpgPrvKey ,
470+ passphrase ,
471+ wrongAdata
472+ ) ,
473+ / 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 /
474+ ) ;
475+ } ) ;
476+
477+ it ( 'should throw when adata does not match (non-domain-separated format)' , async function ( ) {
478+ const passphrase = 'test-password' ;
479+ const correctAdata = 'correct-adata' ;
480+ const wrongAdata = 'wrong-adata' ;
481+
482+ // Encrypt with correct adata (no domain separator)
483+ const encryptedUserGpgPrvKey = bitgo . encrypt ( {
484+ input : userGpgKeyPair . privateKey ,
485+ password : passphrase ,
486+ adata : correctAdata ,
487+ } ) ;
488+
489+ // Try to decrypt with wrong adata
490+ await assert . rejects (
491+ ( tssUtils as any ) . getBitgoAndUserGpgKeys (
492+ bitgoGpgKeyPair . publicKey ,
493+ encryptedUserGpgPrvKey ,
494+ passphrase ,
495+ wrongAdata
496+ ) ,
497+ / 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 /
498+ ) ;
499+ } ) ;
500+
501+ it ( 'should throw when cyphertext is not valid JSON' , async function ( ) {
502+ const passphrase = 'test-password' ;
503+ const adata = 'test-adata' ;
504+ const invalidCyphertext = 'not-valid-json' ;
505+
506+ // SJCL's decrypt() runs before validateAdata, so it throws its own "json decode" error
507+ // before our "Failed to parse cyphertext to JSON" path is ever reached.
508+ await assert . rejects (
509+ ( tssUtils as any ) . getBitgoAndUserGpgKeys ( bitgoGpgKeyPair . publicKey , invalidCyphertext , passphrase , adata ) ,
510+ / j s o n d e c o d e | 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 /
511+ ) ;
512+ } ) ;
513+ } ) ;
514+
515+ describe ( 'validateAdata' , function ( ) {
516+ it ( 'should pass when adata matches with domain separator' , function ( ) {
517+ const adata = 'test-value' ;
518+ const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE' ;
519+ const cyphertext = bitgo . encrypt ( {
520+ input : 'secret' ,
521+ password : 'password' ,
522+ adata : `${ domainSeparator } :${ adata } ` ,
523+ } ) ;
524+
525+ assert . doesNotThrow ( ( ) => {
526+ ( tssUtils as any ) . validateAdata ( adata , cyphertext , domainSeparator ) ;
527+ } ) ;
528+ } ) ;
529+
530+ it ( 'should pass when adata matches without domain separator' , function ( ) {
531+ const adata = 'test-value' ;
532+ const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE' ;
533+ const cyphertext = bitgo . encrypt ( {
534+ input : 'secret' ,
535+ password : 'password' ,
536+ adata : adata ,
537+ } ) ;
538+
539+ assert . doesNotThrow ( ( ) => {
540+ ( tssUtils as any ) . validateAdata ( adata , cyphertext , domainSeparator ) ;
541+ } ) ;
542+ } ) ;
543+
544+ it ( 'should throw when adata does not match' , function ( ) {
545+ const correctAdata = 'correct-value' ;
546+ const wrongAdata = 'wrong-value' ;
547+ const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE' ;
548+ const cyphertext = bitgo . encrypt ( {
549+ input : 'secret' ,
550+ password : 'password' ,
551+ adata : `${ domainSeparator } :${ correctAdata } ` ,
552+ } ) ;
553+
554+ assert . throws (
555+ ( ) => ( tssUtils as any ) . validateAdata ( wrongAdata , cyphertext , domainSeparator ) ,
556+ / 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 /
557+ ) ;
558+ } ) ;
559+
560+ it ( 'should throw when cyphertext is not valid JSON' , function ( ) {
561+ const invalidCyphertext = 'not-json' ;
562+ assert . throws (
563+ ( ) => ( tssUtils as any ) . validateAdata ( 'adata' , invalidCyphertext , 'separator' ) ,
564+ / 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 /
565+ ) ;
566+ } ) ;
567+ } ) ;
568+ } ) ;
569+
301570 // ---------------------------------------------------------------------------
302571 // Nock helpers
303572 // ---------------------------------------------------------------------------
0 commit comments