From 21882908e5b69a0e12aa366ac0482031a29fe2e6 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Mon, 18 May 2026 15:49:18 +0530 Subject: [PATCH] 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 --- .../tssUtils/eddsaMPCv2/createKeychains.ts | 247 ++++++++++++++++++ .../src/bitgo/utils/tss/baseTSSUtils.ts | 43 +++ .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 29 +- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 48 +++- 4 files changed, 340 insertions(+), 27 deletions(-) diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts index 6cb2f33216..d58b2a970d 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts @@ -298,6 +298,253 @@ describe('TSS EdDSA MPCv2 Utils:', async function () { }); }); + describe('External Signing Helpers', function () { + let userGpgKeyPair: openpgp.SerializedKeyPair & { revocationCertificate: string }; + + before(async function () { + openpgp.config.rejectCurves = new Set(); + userGpgKeyPair = await openpgp.generateKey({ + userIDs: [{ name: 'user', email: 'user@test.com' }], + curve: 'ed25519', + format: 'armored', + }); + }); + + describe('getSignableHexAndDerivationPath', function () { + it('should extract signableHex and derivationPath from a valid txRequest', function () { + const txRequest = { + transactions: [ + { + unsignedTx: { + signableHex: 'deadbeef', + derivationPath: 'm/0/0', + serializedTxHex: 'aabbccdd', + }, + }, + ], + }; + + const result = (tssUtils as any).getSignableHexAndDerivationPath(txRequest); + assert.equal(result.signableHex, 'deadbeef'); + assert.equal(result.derivationPath, 'm/0/0'); + }); + + it('should throw when transactions field is missing', function () { + const txRequest = { messages: [{ messageEncoded: 'test' }] }; + + assert.throws( + () => (tssUtils as any).getSignableHexAndDerivationPath(txRequest), + /createOfflineShare requires exactly one transaction in txRequest/ + ); + }); + + it('should throw when transactions array is empty', function () { + const txRequest = { transactions: [] }; + + assert.throws( + () => (tssUtils as any).getSignableHexAndDerivationPath(txRequest), + /createOfflineShare requires exactly one transaction in txRequest/ + ); + }); + + it('should throw when transactions array has more than one element', function () { + const txRequest = { + transactions: [ + { unsignedTx: { signableHex: 'aaa', derivationPath: 'm/0' } }, + { unsignedTx: { signableHex: 'bbb', derivationPath: 'm/1' } }, + ], + }; + + assert.throws( + () => (tssUtils as any).getSignableHexAndDerivationPath(txRequest), + /createOfflineShare requires exactly one transaction in txRequest/ + ); + }); + + it('should throw when signableHex is missing', function () { + const txRequest = { transactions: [{ unsignedTx: { derivationPath: 'm/0' } }] }; + + assert.throws( + () => (tssUtils as any).getSignableHexAndDerivationPath(txRequest), + /Missing signableHex in unsignedTx/ + ); + }); + + it('should throw when derivationPath is missing', function () { + const txRequest = { transactions: [{ unsignedTx: { signableHex: 'deadbeef' } }] }; + + assert.throws( + () => (tssUtils as any).getSignableHexAndDerivationPath(txRequest), + /Missing derivationPath in unsignedTx/ + ); + }); + }); + + describe('getBitgoAndUserGpgKeys', function () { + it('should decrypt v1 SJCL envelope and return GPG keys', async function () { + const passphrase = 'test-password'; + const adata = 'test-adata'; + + // Encrypt user GPG private key with v1 SJCL (no adata for simplicity in v1) + const encryptedUserGpgPrvKey = bitgo.encrypt({ + input: userGpgKeyPair.privateKey, + password: passphrase, + }); + + const result = await (tssUtils as any).getBitgoAndUserGpgKeys( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + passphrase, + adata + ); + + assert.ok(result.bitgoGpgKey); + assert.ok(result.userGpgPrvKey); + assert.ok(result.userGpgPrvKey.constructor.name === 'PrivateKey'); + }); + + it('should decrypt v2 Argon2 envelope and return GPG keys', async function () { + this.timeout(10000); // v2 decryption with Argon2 can be slow + + const passphrase = 'test-password'; + const adata = 'test-adata'; + const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY'; + + // Encrypt user GPG private key with v2 Argon2 + const encryptedUserGpgPrvKey = await bitgo.encryptAsync({ + input: userGpgKeyPair.privateKey, + password: passphrase, + adata: `${domainSeparator}:${adata}`, + }); + + const result = await (tssUtils as any).getBitgoAndUserGpgKeys( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + passphrase, + adata + ); + + assert.ok(result.bitgoGpgKey); + assert.ok(result.userGpgPrvKey); + assert.ok(result.userGpgPrvKey.constructor.name === 'PrivateKey'); + }); + + it('should throw when adata does not match (domain-separated format)', async function () { + const passphrase = 'test-password'; + const correctAdata = 'correct-adata'; + const wrongAdata = 'wrong-adata'; + const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY'; + + // Encrypt with correct adata + const encryptedUserGpgPrvKey = bitgo.encrypt({ + input: userGpgKeyPair.privateKey, + password: passphrase, + adata: `${domainSeparator}:${correctAdata}`, + }); + + // Try to decrypt with wrong adata + await assert.rejects( + (tssUtils as any).getBitgoAndUserGpgKeys( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + passphrase, + wrongAdata + ), + /Adata does not match cyphertext adata/ + ); + }); + + it('should throw when adata does not match (non-domain-separated format)', async function () { + const passphrase = 'test-password'; + const correctAdata = 'correct-adata'; + const wrongAdata = 'wrong-adata'; + + // Encrypt with correct adata (no domain separator) + const encryptedUserGpgPrvKey = bitgo.encrypt({ + input: userGpgKeyPair.privateKey, + password: passphrase, + adata: correctAdata, + }); + + // Try to decrypt with wrong adata + await assert.rejects( + (tssUtils as any).getBitgoAndUserGpgKeys( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + passphrase, + wrongAdata + ), + /Adata does not match cyphertext adata/ + ); + }); + + it('should throw when cyphertext is not valid JSON', async function () { + const passphrase = 'test-password'; + const adata = 'test-adata'; + const invalidCyphertext = 'not-valid-json'; + + await assert.rejects( + (tssUtils as any).getBitgoAndUserGpgKeys(bitgoGpgKeyPair.publicKey, invalidCyphertext, passphrase, adata), + /Failed to parse cyphertext to JSON/ + ); + }); + }); + + describe('validateAdata', function () { + it('should pass when adata matches with domain separator', function () { + const adata = 'test-value'; + const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE'; + const cyphertext = bitgo.encrypt({ + input: 'secret', + password: 'password', + adata: `${domainSeparator}:${adata}`, + }); + + assert.doesNotThrow(() => { + (tssUtils as any).validateAdata(adata, cyphertext, domainSeparator); + }); + }); + + it('should pass when adata matches without domain separator', function () { + const adata = 'test-value'; + const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE'; + const cyphertext = bitgo.encrypt({ + input: 'secret', + password: 'password', + adata: adata, + }); + + assert.doesNotThrow(() => { + (tssUtils as any).validateAdata(adata, cyphertext, domainSeparator); + }); + }); + + it('should throw when adata does not match', function () { + const correctAdata = 'correct-value'; + const wrongAdata = 'wrong-value'; + const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE'; + const cyphertext = bitgo.encrypt({ + input: 'secret', + password: 'password', + adata: `${domainSeparator}:${correctAdata}`, + }); + + assert.throws( + () => (tssUtils as any).validateAdata(wrongAdata, cyphertext, domainSeparator), + /Adata does not match cyphertext adata/ + ); + }); + + it('should throw when cyphertext is not valid JSON', function () { + const invalidCyphertext = 'not-json'; + assert.throws( + () => (tssUtils as any).validateAdata('adata', invalidCyphertext, 'separator'), + /Failed to parse cyphertext to JSON/ + ); + }); + }); + }); + // --------------------------------------------------------------------------- // Nock helpers // --------------------------------------------------------------------------- diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index ec4b79aa86..01f59c5c32 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -650,4 +650,47 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil const { apiVersion, state } = txRequest; return apiVersion === 'full' && 'pendingApproval' === state; } + + /** + * Get the signable hex and derivation path from a full single-transaction request. + * @param {TxRequest} txRequest - the transaction request object + * @returns {{ signableHex: string; derivationPath: string }} - the signable hex and derivation path + */ + protected getSignableHexAndDerivationPath( + txRequest: TxRequest, + missingTransactionsMessage = 'createOfflineShare requires exactly one transaction in txRequest' + ): { + signableHex: string; + derivationPath: string; + } { + assert(txRequest.transactions && txRequest.transactions.length === 1, missingTransactionsMessage); + const unsignedTx = txRequest.transactions[0].unsignedTx; + assert(unsignedTx, 'Missing unsignedTx in transactions'); + assert(unsignedTx.signableHex, 'Missing signableHex in unsignedTx'); + assert(unsignedTx.derivationPath, 'Missing derivationPath in unsignedTx'); + return { signableHex: unsignedTx.signableHex, derivationPath: unsignedTx.derivationPath }; + } + + /** + * Validates encryption additional authenticated data against the ciphertext envelope. + * @param adata string + * @param cyphertext string + * @param roundDomainSeparator string + * @throws {Error} if the adata or cyphertext is invalid + */ + protected validateAdata(adata: string, cyphertext: string, roundDomainSeparator: string): void { + let cypherJson; + try { + cypherJson = JSON.parse(cyphertext); + } catch (e) { + throw new Error('Failed to parse cyphertext to JSON, got: ' + cyphertext); + } + // using decodeURIComponent to handle special characters + if ( + decodeURIComponent(cypherJson.adata) !== decodeURIComponent(`${roundDomainSeparator}:${adata}`) && + decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata) + ) { + throw new Error('Adata does not match cyphertext adata'); + } + } } diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 272d4a1f5e..0ab667c57a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -1014,9 +1014,9 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { let txToSign: string; let derivationPath: string; if (requestType === RequestType.tx) { - assert(txRequest.transactions && txRequest.transactions.length === 1, 'Unable to find transactions in txRequest'); - txToSign = txRequest.transactions[0].unsignedTx.signableHex; - derivationPath = txRequest.transactions[0].unsignedTx.derivationPath; + const signableTx = this.getSignableHexAndDerivationPath(txRequest, 'Unable to find transactions in txRequest'); + txToSign = signableTx.signableHex; + derivationPath = signableTx.derivationPath; } else if (requestType === RequestType.message) { // TODO(WP-2176): Add support for message signing throw new Error('MPCv2 message signing not supported yet.'); @@ -1073,29 +1073,6 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { }; } - /** - * Validates the adata and cyphertext. - * @param adata string - * @param cyphertext string - * @returns void - * @throws {Error} if the adata or cyphertext is invalid - */ - private validateAdata(adata: string, cyphertext: string, roundDomainSeparator: string): void { - let cypherJson; - try { - cypherJson = JSON.parse(cyphertext); - } catch (e) { - throw new Error('Failed to parse cyphertext to JSON, got: ' + cyphertext); - } - // using decodeURIComponent to handle special characters - if ( - decodeURIComponent(cypherJson.adata) !== decodeURIComponent(`${roundDomainSeparator}:${adata}`) && - decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata) - ) { - throw new Error('Adata does not match cyphertext adata'); - } - } - // #endregion // #region external signer diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index afde0fce9a..9f77d8bf12 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -26,11 +26,23 @@ import { } from '../../../tss/eddsa/eddsaMPCv2'; import { generateGPGKeyPair } from '../../opengpgUtils'; import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2'; -import { RequestType, SignatureShareType, TSSParamsForMessageWithPrv, TSSParamsWithPrv, TxRequest } from '../baseTypes'; +import { + RequestType, + SignatureShareType, + TSSParamsForMessageWithPrv, + TSSParamsWithPrv, + TxRequest, + isV2Envelope, +} from '../baseTypes'; import { BaseEddsaUtils } from './base'; import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; export class EddsaMPCv2Utils extends BaseEddsaUtils { + private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; + // TODO(WCI-378): call the MPS_DSG_SIGNING_ROUND1/2_STATE in createOfflineRoundShare handlers + // private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE'; + // private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE'; + /** @inheritdoc */ async createKeychains(params: { passphrase: string; @@ -515,4 +527,38 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { } // #endregion + + // #region private utils + /** + * Gets the BitGo and user GPG keys from the BitGo public GPG key and the encrypted user GPG private key. + * @param {string} bitgoPublicGpgKey - the BitGo public GPG key + * @param {string} encryptedUserGpgPrvKey - the encrypted user GPG private key + * @param {string} walletPassphrase - the wallet passphrase + * @param {string} adata - the additional data to validate the GPG keys + * @returns {Promise<{ bitgoGpgKey: pgp.Key; userGpgPrvKey: pgp.PrivateKey }>} - the BitGo and user GPG keys + */ + private async getBitgoAndUserGpgKeys( + bitgoPublicGpgKey: string, + encryptedUserGpgPrvKey: string, + walletPassphrase: string, + adata: string + ): Promise<{ + bitgoGpgKey: pgp.Key; + userGpgPrvKey: pgp.PrivateKey; + }> { + const bitgoGpgKey = await pgp.readKey({ armoredKey: bitgoPublicGpgKey }); + + let decryptedGpgPrvKey: string; + if (isV2Envelope(encryptedUserGpgPrvKey)) { + decryptedGpgPrvKey = await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase }); + } else { + decryptedGpgPrvKey = this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase }); + } + + if (adata) { + this.validateAdata(adata, encryptedUserGpgPrvKey, EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY); + } + const userGpgPrvKey = await pgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey }); + return { bitgoGpgKey, userGpgPrvKey }; + } }