Skip to content

Commit 2188290

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 2188290

4 files changed

Lines changed: 340 additions & 27 deletions

File tree

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

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
/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 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+
/Adata does not match cyphertext adata/
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+
/Adata does not match cyphertext adata/
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+
/Failed to parse cyphertext to JSON/
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+
/Adata does not match cyphertext adata/
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+
/Failed to parse cyphertext to JSON/
543+
);
544+
});
545+
});
546+
});
547+
301548
// ---------------------------------------------------------------------------
302549
// Nock helpers
303550
// ---------------------------------------------------------------------------

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)