taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit dec00d5919deb70850649dbe3e323aae8951e87e
parent b3b87417f1e1bf42fa2aad4b93167764a470f265
Author: Florian Dold <dold@taler.net>
Date:   Sun, 13 Sep 2026 16:16:09 +0200

wallet-core: reject invalid merchant contract signatures

Check the validity field returned by signature verification instead of
treating the result object itself as a boolean. Invalid signatures must
fail the claim before contract terms are stored.

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 12+++++++-----
2 files changed, 97 insertions(+), 5 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -16,6 +16,12 @@ import { Amounts, AmountString, + buildSigPS, + ContractTermsUtil, + decodeCrock, + eddsaGetPublic, + eddsaSign, + encodeCrock, HttpStatusCode, MerchantContractVersion, RefreshReason, @@ -26,6 +32,7 @@ import { TalerPreciseTimestamp, TalerError, TalerErrorCode, + TalerSignaturePurpose, TimerAPI, TimerGroup, TimerHandle, @@ -59,6 +66,7 @@ import { failProposalClaimPermanently, FAILED_CLAIM_RETENTION_MS, preparePayForUriV2, + processPurchase, generateTokenSigs, getCoinsToSpendForMerchantRepair, getAlreadyPaidRefundRequests, @@ -83,6 +91,8 @@ import { } from "./pay-merchant.js"; import { makeIdbRunner } from "./db/testing/runners.js"; import type { WalletExecutionContext } from "./wallet.js"; +import { nativeCrypto } from "./crypto/cryptoImplementation.js"; +import { HeadersImpl } from "@gnu-taler/taler-util/http"; interface ScheduledTimer { due: number; @@ -159,6 +169,86 @@ function claimPurchase( }; } +test("proposal download accepts only valid merchant contract signatures", async () => { + const runner = await makeIdbRunner(); + try { + for (const valid of [false, true]) { + const proposal = claimPurchase(`claim-signature-${valid}`); + const deadline = { t_s: Math.floor(Date.now() / 1000) + 86400 }; + const priv = new Uint8Array(32).fill(9); + const contract = { + amount: "TEST:1", + max_fee: "TEST:0", + order_id: proposal.orderId, + nonce: proposal.noncePub, + merchant_base_url: proposal.merchantBaseUrl, + h_wire: encodeCrock(new Uint8Array(64)), + wire_method: "iban", + summary: "signature test", + pay_deadline: deadline, + refund_deadline: deadline, + wire_transfer_deadline: deadline, + timestamp: { t_s: Math.floor(Date.now() / 1000) }, + merchant: { name: "review" }, + merchant_pub: encodeCrock(eddsaGetPublic(priv)), + exchanges: [], + }; + const h = ContractTermsUtil.hashContractTerms(contract); + const signature = eddsaSign( + buildSigPS(TalerSignaturePurpose.MERCHANT_CONTRACT) + .put(decodeCrock(h)) + .build(), + priv, + ); + const body = { + contract_terms: contract, + sig: encodeCrock(valid ? signature : new Uint8Array(64)), + }; + const wex = { + ws: { + networkAvailable: true, + leftoverClaims: new Set(), + timerGroup: new ManualTimer(), + }, + runWalletDbTx: <T>(f: (tx: WalletDbTransaction) => Promise<T>) => + runner.runReadWriteTx(f), + cryptoApi: nativeCrypto, + http: { + fetch: async (url: string) => ({ + status: 200, + requestUrl: url, + requestMethod: "POST", + headers: new HeadersImpl(), + json: async () => body, + text: async () => JSON.stringify(body), + }), + }, + } as unknown as WalletExecutionContext; + await runner.runReadWriteTx((tx) => tx.upsertPurchase(proposal)); + await processPurchase(wex, proposal.proposalId); + const stored = await runner.runReadWriteTx((tx) => + tx.getPurchase(proposal.proposalId), + ); + assert.strictEqual( + stored?.purchaseStatus, + valid ? PurchaseStatus.DialogProposed : PurchaseStatus.FailedClaim, + ); + const terms = await runner.runReadWriteTx((tx) => tx.getContractTerms(h)); + if (valid) assert.ok(terms); + else { + assert.strictEqual(terms, undefined); + assert.strictEqual(stored?.download, undefined); + assert.strictEqual( + stored?.failReason?.code, + TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID, + ); + } + } + } finally { + await runner.close(); + } +}); + test("payment-share flags use the shared transaction path", () => { assert.strictEqual( isSharedPurchase({ shared: true, createdFromShared: false }), diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1663,11 +1663,13 @@ async function processDownloadProposal( return TaskRunResult.finished(); } - const sigValid = await wex.cryptoApi.isValidContractTermsSignature({ - contractTermsHash, - merchantPub: parsedContractTerms.merchant_pub, - sig: proposalResp.sig, - }); + const { valid: sigValid } = await wex.cryptoApi.isValidContractTermsSignature( + { + contractTermsHash, + merchantPub: parsedContractTerms.merchant_pub, + sig: proposalResp.sig, + }, + ); if (!sigValid) { const err = makeErrorDetail(