diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index ec4b79aa86..1d86001223 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -41,6 +41,7 @@ import { TssSignTxRequestParamsWithPrv, TxRequest, TxRequestVersion, + isV2Envelope, } from './baseTypes'; import { GShare, SignShare } from '../../../account-lib/mpc/tss'; import { RequestTracer } from '../util'; @@ -650,4 +651,84 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil const { apiVersion, state } = txRequest; return apiVersion === 'full' && 'pendingApproval' === state; } + + /** + * Get the signable hex, derivation path, and serialized tx hex from a full single-transaction request. + * @param {TxRequest} txRequest - the transaction request object + * @returns {{ signableHex: string; derivationPath: string; serializedTxHex: string | undefined }} - the signable hex, derivation path, and serialized tx hex + */ + protected getSignableHexAndDerivationPath( + txRequest: TxRequest, + missingTransactionsMessage = 'createOfflineShare requires exactly one transaction in txRequest' + ): { + signableHex: string; + derivationPath: string; + serializedTxHex: string | undefined; + } { + 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, + serializedTxHex: unsignedTx.serializedTxHex, + }; + } + + /** + * 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 + * @param {string} userGpgKeyDomainSeparator - the domain separator expected in the encrypted GPG key adata + * @returns {Promise<{ bitgoGpgKey: openpgp.Key; userGpgPrvKey: openpgp.PrivateKey }>} - the BitGo and user GPG keys + */ + protected async getBitgoAndUserGpgKeys( + bitgoPublicGpgKey: string, + encryptedUserGpgPrvKey: string, + walletPassphrase: string, + adata: string, + userGpgKeyDomainSeparator: string + ): Promise<{ + bitgoGpgKey: openpgp.Key; + userGpgPrvKey: openpgp.PrivateKey; + }> { + const bitgoGpgKey = await openpgp.readKey({ armoredKey: bitgoPublicGpgKey }); + + const decryptedGpgPrvKey = isV2Envelope(encryptedUserGpgPrvKey) + ? await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase }) + : this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase }); + + if (adata) { + this.validateAdata(adata, encryptedUserGpgPrvKey, userGpgKeyDomainSeparator); + } + const userGpgPrvKey = await openpgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey }); + return { bitgoGpgKey, userGpgPrvKey }; + } + + /** + * 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 3a1fa31368..371cedd255 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -1015,10 +1015,10 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { let derivationPath: string; let serializedTxHex: string | undefined; 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; - serializedTxHex = txRequest.transactions[0].unsignedTx.serializedTxHex; + const signableTx = this.getSignableHexAndDerivationPath(txRequest, 'Unable to find transactions in txRequest'); + txToSign = signableTx.signableHex; + derivationPath = signableTx.derivationPath; + serializedTxHex = signableTx.serializedTxHex; } else if (requestType === RequestType.message) { // TODO(WP-2176): Add support for message signing throw new Error('MPCv2 message signing not supported yet.'); @@ -1051,67 +1051,6 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { return { hashBuffer, derivationPath }; } - /** - * 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; userGpgKey: pgp.SerializedKeyPair }>} - the BitGo and user GPG keys - */ - private async getBitgoAndUserGpgKeys( - bitgoPublicGpgKey: string, - encryptedUserGpgPrvKey: string, - walletPassphrase: string, - adata: string - ): Promise<{ - bitgoGpgKey: pgp.Key; - userGpgKey: pgp.SerializedKeyPair; - }> { - 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, EcdsaMPCv2Utils.DKLS23_SIGNING_USER_GPG_KEY); - } - const userDecryptedKey = await pgp.readKey({ armoredKey: decryptedGpgPrvKey }); - const userGpgKey: pgp.SerializedKeyPair = { - privateKey: userDecryptedKey.armor(), - publicKey: userDecryptedKey.toPublic().armor(), - }; - return { - bitgoGpgKey, - userGpgKey, - }; - } - - /** - * 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 @@ -1286,12 +1225,17 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { const useV2 = isV2Envelope(encryptedRound1Session); - const { bitgoGpgKey, userGpgKey } = await this.getBitgoAndUserGpgKeys( + const { bitgoGpgKey, userGpgPrvKey } = await this.getBitgoAndUserGpgKeys( bitgoPublicGpgKey, encryptedUserGpgPrvKey, walletPassphrase, - adata + adata, + EcdsaMPCv2Utils.DKLS23_SIGNING_USER_GPG_KEY ); + const userGpgKey: pgp.SerializedKeyPair = { + privateKey: userGpgPrvKey.armor(), + publicKey: userGpgPrvKey.toPublic().armor(), + }; const signatureShares = txRequest.transactions?.[0].signatureShares; assert(signatureShares, 'Missing signature shares in round 1 txRequest'); @@ -1380,12 +1324,17 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { const useV2 = isV2Envelope(encryptedRound2Session); - const { bitgoGpgKey, userGpgKey } = await this.getBitgoAndUserGpgKeys( + const { bitgoGpgKey, userGpgPrvKey } = await this.getBitgoAndUserGpgKeys( bitgoPublicGpgKey, encryptedUserGpgPrvKey, walletPassphrase, - adata + adata, + EcdsaMPCv2Utils.DKLS23_SIGNING_USER_GPG_KEY ); + const userGpgKey: pgp.SerializedKeyPair = { + privateKey: userGpgPrvKey.armor(), + publicKey: userGpgPrvKey.toPublic().armor(), + }; const signatureShares = txRequest.transactions?.[0].signatureShares; assert(signatureShares, 'Missing signature shares in round 2 txRequest'); 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..c255a34971 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -31,6 +31,10 @@ import { BaseEddsaUtils } from './base'; import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; export class EddsaMPCv2Utils extends BaseEddsaUtils { + // 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; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts new file mode 100644 index 0000000000..597ea4f7cc --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts @@ -0,0 +1,415 @@ +import * as assert from 'assert'; +import { Hash, randomBytes } from 'crypto'; +import createKeccakHash from 'keccak'; +import * as openpgp from 'openpgp'; +import * as sinon from 'sinon'; +import * as sjcl from '@bitgo/sjcl'; +import { DklsUtils } from '@bitgo/sdk-lib-mpc'; + +import { BitGoBase, EcdsaMPCv2Utils, IBaseCoin, TxRequest } from '../../../../../src'; +import BaseTssUtils from '../../../../../src/bitgo/utils/tss/baseTSSUtils'; + +type BitgoGpgKeyPair = openpgp.SerializedKeyPair & { revocationCertificate: string }; +type TxRequestTransaction = NonNullable[number]; +type UnsignedTransaction = TxRequestTransaction['unsignedTx']; +type SignableHexAndDerivationPath = { + signableHex: string; + derivationPath: string; + serializedTxHex: string | undefined; +}; +type DecryptedGpgKeys = { bitgoGpgKey: openpgp.Key; userGpgPrvKey: openpgp.PrivateKey }; + +class TestBaseTssUtils extends BaseTssUtils { + getSignableHexAndDerivationPathForTest( + txRequest: TxRequest, + missingTransactionsMessage?: string + ): SignableHexAndDerivationPath { + return this.getSignableHexAndDerivationPath(txRequest, missingTransactionsMessage); + } + + validateAdataForTest(adata: string, cyphertext: string, roundDomainSeparator: string): void { + this.validateAdata(adata, cyphertext, roundDomainSeparator); + } + + getBitgoAndUserGpgKeysForTest( + bitgoPublicGpgKey: string, + encryptedUserGpgPrvKey: string, + walletPassphrase: string, + adata: string, + userGpgKeyDomainSeparator: string + ): Promise { + return this.getBitgoAndUserGpgKeys( + bitgoPublicGpgKey, + encryptedUserGpgPrvKey, + walletPassphrase, + adata, + userGpgKeyDomainSeparator + ); + } +} + +describe('Base TSS Utils', function () { + const walletPassphrase = 'test-password'; + const signingUserGpgKeyDomainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY'; + const roundOneDomainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE'; + + let baseTssUtils: TestBaseTssUtils; + let ecdsaMPCv2Utils: EcdsaMPCv2Utils; + let mockBitgo: BitGoBase; + let decryptAsyncStub: sinon.SinonStub; + let bitgoGpgKeyPair: BitgoGpgKeyPair; + let userGpgKeyPair: BitgoGpgKeyPair; + let userShare: Buffer; + + before(async function () { + openpgp.config.rejectCurves = new Set(); + bitgoGpgKeyPair = await openpgp.generateKey({ + userIDs: [{ name: 'bitgo', email: 'bitgo@test.com' }], + curve: 'ed25519', + format: 'armored', + }); + userGpgKeyPair = await openpgp.generateKey({ + userIDs: [{ name: 'user', email: 'user@test.com' }], + curve: 'ed25519', + format: 'armored', + }); + + const [userDkgSession] = await DklsUtils.generateDKGKeyShares(); + userShare = userDkgSession.getKeyShare(); + }); + + beforeEach(function () { + decryptAsyncStub = sinon.stub().callsFake(({ input }: Parameters[0]) => + // Fake v2 envelope: resolve the plaintext embedded in the JSON. + Promise.resolve((JSON.parse(input) as { plaintext: string }).plaintext) + ); + + const mockBg = {} as BitGoBase; + mockBg.getEnv = sinon.stub().returns('test'); + mockBg.encrypt = sinon + .stub() + .callsFake((params) => encryptWithSjcl(params.password ?? '', params.input, params.adata)); + mockBg.decrypt = sinon.stub().callsFake((params) => sjcl.decrypt(params.password ?? '', params.input)); + mockBg.decryptAsync = decryptAsyncStub; + mockBitgo = mockBg; + + const mockCoin = {} as IBaseCoin; + mockCoin.getHashFunction = sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash); + + baseTssUtils = new TestBaseTssUtils(mockBitgo, mockCoin); + ecdsaMPCv2Utils = new EcdsaMPCv2Utils(mockBitgo, mockCoin); + }); + + describe('getSignableHexAndDerivationPath', function () { + it('extracts signableHex and derivationPath from a valid txRequest', function () { + const txRequest = buildTxRequest({ + transactions: [ + buildTransaction({ + unsignedTx: buildUnsignedTransaction({ signableHex: 'deadbeef', derivationPath: 'm/0/0' }), + }), + ], + }); + + const result = baseTssUtils.getSignableHexAndDerivationPathForTest(txRequest); + assert.equal(result.signableHex, 'deadbeef'); + assert.equal(result.derivationPath, 'm/0/0'); + }); + + it('throws when transactions field is missing', function () { + assert.throws( + () => baseTssUtils.getSignableHexAndDerivationPathForTest(buildTxRequestWithoutTransactions()), + /createOfflineShare requires exactly one transaction in txRequest/ + ); + }); + + it('throws when transactions array is empty', function () { + assert.throws( + () => baseTssUtils.getSignableHexAndDerivationPathForTest(buildTxRequest({ transactions: [] })), + /createOfflineShare requires exactly one transaction in txRequest/ + ); + }); + + it('throws when transactions array has more than one element', function () { + assert.throws( + () => + baseTssUtils.getSignableHexAndDerivationPathForTest( + buildTxRequest({ + transactions: [ + buildTransaction({ + unsignedTx: buildUnsignedTransaction({ signableHex: 'aaa', derivationPath: 'm/0' }), + }), + buildTransaction({ + unsignedTx: buildUnsignedTransaction({ signableHex: 'bbb', derivationPath: 'm/1' }), + }), + ], + }) + ), + /createOfflineShare requires exactly one transaction in txRequest/ + ); + }); + + it('throws when unsignedTx is missing', function () { + // @ts-expect-error Intentionally malformed to exercise the runtime guard. + const noUnsignedTx: TxRequestTransaction = { state: 'pendingSignature', signatureShares: [] }; + assert.throws( + () => baseTssUtils.getSignableHexAndDerivationPathForTest(buildTxRequest({ transactions: [noUnsignedTx] })), + /Missing unsignedTx in transactions/ + ); + }); + + it('throws when signableHex is missing', function () { + // @ts-expect-error Intentionally malformed to exercise the runtime guard. + const noSignableHex: UnsignedTransaction = { serializedTxHex: 'aabbccdd', derivationPath: 'm/0' }; + assert.throws( + () => + baseTssUtils.getSignableHexAndDerivationPathForTest( + buildTxRequest({ transactions: [buildTransaction({ unsignedTx: noSignableHex })] }) + ), + /Missing signableHex in unsignedTx/ + ); + }); + + it('throws when derivationPath is missing', function () { + // @ts-expect-error Intentionally malformed to exercise the runtime guard. + const noDerivationPath: UnsignedTransaction = { serializedTxHex: 'aabbccdd', signableHex: 'deadbeef' }; + assert.throws( + () => + baseTssUtils.getSignableHexAndDerivationPathForTest( + buildTxRequest({ transactions: [buildTransaction({ unsignedTx: noDerivationPath })] }) + ), + /Missing derivationPath in unsignedTx/ + ); + }); + }); + + describe('validateAdata', function () { + it('passes when adata matches with domain separator', function () { + const adata = 'test-value'; + const cyphertext = mockBitgo.encrypt({ + input: 'secret', + password: walletPassphrase, + adata: `${roundOneDomainSeparator}:${adata}`, + }); + assert.doesNotThrow(() => baseTssUtils.validateAdataForTest(adata, cyphertext, roundOneDomainSeparator)); + }); + + it('passes when adata matches without domain separator', function () { + const adata = 'test-value'; + const cyphertext = mockBitgo.encrypt({ input: 'secret', password: walletPassphrase, adata }); + assert.doesNotThrow(() => baseTssUtils.validateAdataForTest(adata, cyphertext, roundOneDomainSeparator)); + }); + + it('throws when adata does not match', function () { + const cyphertext = mockBitgo.encrypt({ + input: 'secret', + password: walletPassphrase, + adata: `${roundOneDomainSeparator}:correct`, + }); + assert.throws( + () => baseTssUtils.validateAdataForTest('wrong', cyphertext, roundOneDomainSeparator), + /Adata does not match cyphertext adata/ + ); + }); + + it('throws when cyphertext is not valid JSON', function () { + assert.throws( + () => baseTssUtils.validateAdataForTest('adata', 'not-json', roundOneDomainSeparator), + /Failed to parse cyphertext to JSON/ + ); + }); + }); + + describe('getBitgoAndUserGpgKeys', function () { + it('decrypts v1 SJCL envelope without adata and skips validation', async function () { + const encryptedUserGpgPrvKey = mockBitgo.encrypt({ + input: userGpgKeyPair.privateKey, + password: walletPassphrase, + }); + + const result = await baseTssUtils.getBitgoAndUserGpgKeysForTest( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + walletPassphrase, + '', + signingUserGpgKeyDomainSeparator + ); + + assert.ok(result.bitgoGpgKey); + assert.equal(result.userGpgPrvKey.isPrivate(), true); + }); + + it('decrypts v1 SJCL envelope with matching adata', async function () { + const adata = 'test-adata'; + const encryptedUserGpgPrvKey = mockBitgo.encrypt({ + input: userGpgKeyPair.privateKey, + password: walletPassphrase, + adata: `${signingUserGpgKeyDomainSeparator}:${adata}`, + }); + + const result = await baseTssUtils.getBitgoAndUserGpgKeysForTest( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + walletPassphrase, + adata, + signingUserGpgKeyDomainSeparator + ); + + assert.ok(result.bitgoGpgKey); + assert.equal(result.userGpgPrvKey.isPrivate(), true); + }); + + it('decrypts v2 envelope and returns GPG keys', async function () { + const adata = 'test-adata'; + // Fake v2 envelope: JSON with v:2 triggers isV2Envelope; decryptAsync stub returns plaintext directly. + const encryptedUserGpgPrvKey = JSON.stringify({ + v: 2, + adata: `${signingUserGpgKeyDomainSeparator}:${adata}`, + plaintext: userGpgKeyPair.privateKey, + }); + + const result = await baseTssUtils.getBitgoAndUserGpgKeysForTest( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + walletPassphrase, + adata, + signingUserGpgKeyDomainSeparator + ); + + sinon.assert.calledOnce(decryptAsyncStub); + assert.ok(result.bitgoGpgKey); + assert.equal(result.userGpgPrvKey.isPrivate(), true); + }); + + it('throws when adata does not match', async function () { + const encryptedUserGpgPrvKey = mockBitgo.encrypt({ + input: userGpgKeyPair.privateKey, + password: walletPassphrase, + adata: `${signingUserGpgKeyDomainSeparator}:correct-adata`, + }); + + await assert.rejects( + baseTssUtils.getBitgoAndUserGpgKeysForTest( + bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey, + walletPassphrase, + 'wrong-adata', + signingUserGpgKeyDomainSeparator + ), + /Adata does not match cyphertext adata/ + ); + }); + + it('throws when cyphertext is not valid JSON', async function () { + await assert.rejects( + baseTssUtils.getBitgoAndUserGpgKeysForTest( + bitgoGpgKeyPair.publicKey, + 'not-valid-json', + walletPassphrase, + 'test-adata', + signingUserGpgKeyDomainSeparator + ), + /json decode|Failed to parse cyphertext to JSON/ + ); + }); + }); + + describe('ECDSA MPC v2 delegated txRequest parsing', function () { + it('getHashStringAndDerivationPath produces the correct hashBuffer and derivationPath', async function () { + const signableHex = 'deadbeef'; + const derivationPath = 'm/0'; + const txRequest = buildTxRequest({ + transactions: [buildTransaction({ unsignedTx: buildUnsignedTransaction({ signableHex, derivationPath }) })], + }); + const expectedHashBuffer = createKeccakHash('keccak256').update(Buffer.from(signableHex, 'hex')).digest(); + + const { encryptedRound1Session } = await ecdsaMPCv2Utils.createOfflineRound1Share({ + txRequest, + prv: userShare.toString('base64'), + walletPassphrase, + }); + + // The round-1 session is encrypted with adata = "DKLS23_SIGNING_ROUND1_STATE::", + // which proves getHashStringAndDerivationPath produced the right values after the delegation refactor. + const { adata } = JSON.parse(encryptedRound1Session) as { adata?: string }; + assert.equal( + adata === undefined ? adata : decodeURIComponent(adata), + `DKLS23_SIGNING_ROUND1_STATE:${expectedHashBuffer.toString('hex')}:${derivationPath}` + ); + }); + + it('propagates the base unsignedTx guard through the ECDSA route', async function () { + // @ts-expect-error Intentionally malformed to exercise the runtime guard. + const noUnsignedTx: TxRequestTransaction = { state: 'pendingSignature', signatureShares: [] }; + await assert.rejects( + ecdsaMPCv2Utils.createOfflineRound1Share({ + txRequest: buildTxRequest({ transactions: [noUnsignedTx] }), + prv: 'unused', + walletPassphrase, + }), + /Missing unsignedTx in transactions/ + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Fixture builders +// --------------------------------------------------------------------------- + +function buildTxRequest(overrides: Partial = {}): TxRequest { + return { + txRequestId: 'tx-request-id', + walletId: 'wallet-id', + walletType: 'hot', + version: 1, + state: 'initialized', + date: new Date(0).toISOString(), + userId: 'user-id', + intent: {}, + policiesChecked: true, + unsignedTxs: [], + transactions: [buildTransaction()], + latest: true, + ...overrides, + }; +} + +function buildTxRequestWithoutTransactions(): TxRequest { + const txRequest = buildTxRequest(); + delete txRequest.transactions; + return txRequest; +} + +function buildTransaction(overrides: Partial = {}): TxRequestTransaction { + return { state: 'pendingSignature', unsignedTx: buildUnsignedTransaction(), signatureShares: [], ...overrides }; +} + +function buildUnsignedTransaction(overrides: Partial = {}): UnsignedTransaction { + return { serializedTxHex: 'aabbccdd', signableHex: 'deadbeef', derivationPath: 'm/0', ...overrides }; +} + +// --------------------------------------------------------------------------- +// Encryption helpers (mirrors the ecdsaMPCv2 test pattern) +// --------------------------------------------------------------------------- + +function encryptWithSjcl(password: string, input: string, adata?: string): string { + const salt = randomBytes(8); + const iv = randomBytes(16); + return sjcl.encrypt(password, input, { + salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4))], + iv: [ + bytesToWord(iv.subarray(0, 4)), + bytesToWord(iv.subarray(4, 8)), + bytesToWord(iv.subarray(8, 12)), + bytesToWord(iv.subarray(12, 16)), + ], + ...(adata !== undefined ? { adata } : {}), + }); +} + +function bytesToWord(bytes: Uint8Array): number { + if (bytes.length !== 4) { + throw new Error('bytes must have length 4'); + } + return bytes.reduce((num, byte) => num * 0x100 + byte, 0); +}