Skip to content

Commit 6340d35

Browse files
committed
feat(sdk-core): add EdDSA MPCv2 offline signing helper infrastructure
Ticket: WCI-386 Adds EdDSA MPCv2 offline signing helper infrastructure and centralizes common MPCv2 helper logic in BaseTssUtils for reuse across ECDSA and EdDSA. The shared helpers cover transaction payload extraction and authenticated data validation while keeping scheme-specific signing behavior local. - Add MPS_DSG_SIGNING_USER_GPG_KEY domain-separator constant for adata prefixes - Add getBitgoAndUserGpgKeys() to decrypt user GPG keys with v1 (SJCL) and v2 (Argon2id) envelope support - Move getSignableHexAndDerivationPath() into BaseTssUtils for shared ECDSA and EdDSA MPCv2 transaction extraction - Move validateAdata() into BaseTssUtils to eliminate duplicated authenticated data validation - Reuse shared transaction extraction from ECDSA before scheme-specific hashing - Import isV2Envelope from baseTypes for envelope version detection - Add comprehensive test coverage for helper behavior
1 parent 8b304dd commit 6340d35

4 files changed

Lines changed: 362 additions & 27 deletions

File tree

modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
/createOfflineShare requires exactly one transaction in txRequest/
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+
/createOfflineShare requires exactly one transaction in txRequest/
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+
/createOfflineShare requires exactly one transaction in txRequest/
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+
/Missing signableHex in unsignedTx/
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+
/Missing derivationPath in unsignedTx/
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+
/Adata does not match cyphertext adata/
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+
/Adata does not match cyphertext adata/
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+
/json decode|Failed to parse cyphertext to JSON/
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+
/Adata does not match cyphertext adata/
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+
/Failed to parse cyphertext to JSON/
565+
);
566+
});
567+
});
568+
});
569+
301570
// ---------------------------------------------------------------------------
302571
// Nock helpers
303572
// ---------------------------------------------------------------------------

modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,4 +650,47 @@ export default class BaseTssUtils<KeyShare> extends MpcUtils implements ITssUtil
650650
const { apiVersion, state } = txRequest;
651651
return apiVersion === 'full' && 'pendingApproval' === state;
652652
}
653+
654+
/**
655+
* Get the signable hex and derivation path from a full single-transaction request.
656+
* @param {TxRequest} txRequest - the transaction request object
657+
* @returns {{ signableHex: string; derivationPath: string }} - the signable hex and derivation path
658+
*/
659+
protected getSignableHexAndDerivationPath(
660+
txRequest: TxRequest,
661+
missingTransactionsMessage = 'createOfflineShare requires exactly one transaction in txRequest'
662+
): {
663+
signableHex: string;
664+
derivationPath: string;
665+
} {
666+
assert(txRequest.transactions && txRequest.transactions.length === 1, missingTransactionsMessage);
667+
const unsignedTx = txRequest.transactions[0].unsignedTx;
668+
assert(unsignedTx, 'Missing unsignedTx in transactions');
669+
assert(unsignedTx.signableHex, 'Missing signableHex in unsignedTx');
670+
assert(unsignedTx.derivationPath, 'Missing derivationPath in unsignedTx');
671+
return { signableHex: unsignedTx.signableHex, derivationPath: unsignedTx.derivationPath };
672+
}
673+
674+
/**
675+
* Validates encryption additional authenticated data against the ciphertext envelope.
676+
* @param adata string
677+
* @param cyphertext string
678+
* @param roundDomainSeparator string
679+
* @throws {Error} if the adata or cyphertext is invalid
680+
*/
681+
protected validateAdata(adata: string, cyphertext: string, roundDomainSeparator: string): void {
682+
let cypherJson;
683+
try {
684+
cypherJson = JSON.parse(cyphertext);
685+
} catch (e) {
686+
throw new Error('Failed to parse cyphertext to JSON, got: ' + cyphertext);
687+
}
688+
// using decodeURIComponent to handle special characters
689+
if (
690+
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(`${roundDomainSeparator}:${adata}`) &&
691+
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata)
692+
) {
693+
throw new Error('Adata does not match cyphertext adata');
694+
}
695+
}
653696
}

modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,9 +1014,9 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
10141014
let txToSign: string;
10151015
let derivationPath: string;
10161016
if (requestType === RequestType.tx) {
1017-
assert(txRequest.transactions && txRequest.transactions.length === 1, 'Unable to find transactions in txRequest');
1018-
txToSign = txRequest.transactions[0].unsignedTx.signableHex;
1019-
derivationPath = txRequest.transactions[0].unsignedTx.derivationPath;
1017+
const signableTx = this.getSignableHexAndDerivationPath(txRequest, 'Unable to find transactions in txRequest');
1018+
txToSign = signableTx.signableHex;
1019+
derivationPath = signableTx.derivationPath;
10201020
} else if (requestType === RequestType.message) {
10211021
// TODO(WP-2176): Add support for message signing
10221022
throw new Error('MPCv2 message signing not supported yet.');
@@ -1073,29 +1073,6 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
10731073
};
10741074
}
10751075

1076-
/**
1077-
* Validates the adata and cyphertext.
1078-
* @param adata string
1079-
* @param cyphertext string
1080-
* @returns void
1081-
* @throws {Error} if the adata or cyphertext is invalid
1082-
*/
1083-
private validateAdata(adata: string, cyphertext: string, roundDomainSeparator: string): void {
1084-
let cypherJson;
1085-
try {
1086-
cypherJson = JSON.parse(cyphertext);
1087-
} catch (e) {
1088-
throw new Error('Failed to parse cyphertext to JSON, got: ' + cyphertext);
1089-
}
1090-
// using decodeURIComponent to handle special characters
1091-
if (
1092-
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(`${roundDomainSeparator}:${adata}`) &&
1093-
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata)
1094-
) {
1095-
throw new Error('Adata does not match cyphertext adata');
1096-
}
1097-
}
1098-
10991076
// #endregion
11001077

11011078
// #region external signer

0 commit comments

Comments
 (0)