Skip to content

Commit 73179bb

Browse files
committed
feat(sdk-core): add EdDSA MPCv2 offline signing helper infrastructure
Adds shared private utility methods to EddsaMPCv2Utils that will be used across all three createOfflineRound1/2/3Share methods. These are prerequisite helpers that centralize transaction payload extraction, GPG key handling, and authenticated data validation to prevent code duplication. - Add domain-separator constant MPS_DSG_SIGNING_USER_GPG_KEY for adata prefixes - Add getSignableHexAndDerivationPath() to extract signableHex and derivationPath from txRequest - Add getBitgoAndUserGpgKeys() to decrypt user GPG keys with v1 (SJCL) and v2 (Argon2id) envelope support - Add validateAdata() to validate authenticated data matches cyphertext adata - Import isV2Envelope from baseTypes for envelope version detection - Add comprehensive test coverage for all three helper methods in createKeychains.ts Ticket: WCI-386
1 parent 8b304dd commit 73179bb

2 files changed

Lines changed: 337 additions & 1 deletion

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/eddsa/eddsaMPCv2.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,23 @@ import {
2626
} from '../../../tss/eddsa/eddsaMPCv2';
2727
import { generateGPGKeyPair } from '../../opengpgUtils';
2828
import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2';
29-
import { RequestType, SignatureShareType, TSSParamsForMessageWithPrv, TSSParamsWithPrv, TxRequest } from '../baseTypes';
29+
import {
30+
RequestType,
31+
SignatureShareType,
32+
TSSParamsForMessageWithPrv,
33+
TSSParamsWithPrv,
34+
TxRequest,
35+
isV2Envelope,
36+
} from '../baseTypes';
3037
import { BaseEddsaUtils } from './base';
3138
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
3239

3340
export class EddsaMPCv2Utils extends BaseEddsaUtils {
41+
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
42+
// TODO(WCI-378): call the MPS_DSG_SIGNING_ROUND1/2_STATE in createOfflineRoundShare handlers
43+
// private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
44+
// private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE';
45+
3446
/** @inheritdoc */
3547
async createKeychains(params: {
3648
passphrase: string;
@@ -515,4 +527,81 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
515527
}
516528

517529
// #endregion
530+
531+
// #region private utils
532+
/**
533+
* Get the signable hex and derivation path from the transaction request.
534+
* @param {TxRequest} txRequest - the transaction request object
535+
* @returns {{ signableHex: string; derivationPath: string }} - the signable hex and derivation path
536+
*/
537+
private getSignableHexAndDerivationPath(txRequest: TxRequest): {
538+
signableHex: string;
539+
derivationPath: string;
540+
} {
541+
assert(
542+
txRequest.transactions && txRequest.transactions.length === 1,
543+
'createOfflineShare requires exactly one transaction in txRequest'
544+
);
545+
const unsignedTx = txRequest.transactions[0].unsignedTx;
546+
assert(unsignedTx.signableHex, 'Missing signableHex in unsignedTx');
547+
assert(unsignedTx.derivationPath, 'Missing derivationPath in unsignedTx');
548+
return { signableHex: unsignedTx.signableHex, derivationPath: unsignedTx.derivationPath };
549+
}
550+
551+
/**
552+
* Gets the BitGo and user GPG keys from the BitGo public GPG key and the encrypted user GPG private key.
553+
* @param {string} bitgoPublicGpgKey - the BitGo public GPG key
554+
* @param {string} encryptedUserGpgPrvKey - the encrypted user GPG private key
555+
* @param {string} walletPassphrase - the wallet passphrase
556+
* @param {string} adata - the additional data to validate the GPG keys
557+
* @returns {Promise<{ bitgoGpgKey: pgp.Key; userGpgPrvKey: pgp.PrivateKey }>} - the BitGo and user GPG keys
558+
*/
559+
private async getBitgoAndUserGpgKeys(
560+
bitgoPublicGpgKey: string,
561+
encryptedUserGpgPrvKey: string,
562+
walletPassphrase: string,
563+
adata: string
564+
): Promise<{
565+
bitgoGpgKey: pgp.Key;
566+
userGpgPrvKey: pgp.PrivateKey;
567+
}> {
568+
const bitgoGpgKey = await pgp.readKey({ armoredKey: bitgoPublicGpgKey });
569+
570+
let decryptedGpgPrvKey: string;
571+
if (isV2Envelope(encryptedUserGpgPrvKey)) {
572+
decryptedGpgPrvKey = await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase });
573+
} else {
574+
decryptedGpgPrvKey = this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase });
575+
}
576+
577+
this.validateAdata(adata, encryptedUserGpgPrvKey, EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY);
578+
579+
const userGpgPrvKey = await pgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey });
580+
return { bitgoGpgKey, userGpgPrvKey };
581+
}
582+
583+
/**
584+
* Validates the adata and cyphertext.
585+
* @param adata string
586+
* @param cyphertext string
587+
* @param roundDomainSeparator string
588+
* @returns void
589+
* @throws {Error} if the adata or cyphertext is invalid
590+
*/
591+
private validateAdata(adata: string, cyphertext: string, roundDomainSeparator: string): void {
592+
let cypherJson;
593+
try {
594+
cypherJson = JSON.parse(cyphertext);
595+
} catch (e) {
596+
throw new Error('Failed to parse cyphertext to JSON, got: ' + cyphertext);
597+
}
598+
// using decodeURIComponent to handle special characters
599+
if (
600+
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(`${roundDomainSeparator}:${adata}`) &&
601+
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata)
602+
) {
603+
throw new Error('Adata does not match cyphertext adata');
604+
}
605+
}
606+
// #endregion
518607
}

0 commit comments

Comments
 (0)