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 }; + } }