taler-typescript-core

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

commit b92cc7c90ce2c383318f8d67ec2e072cce55f947
parent 977baec467762efdf19eac21a6a5e00336b32e60
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:47 +0200

wallet-core: authenticate refund and purse responses

Diffstat:
Apackages/taler-wallet-core/src/exchange-signatures.test.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/exchange-signatures.ts | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 32++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 12++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 9+++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 8++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 18++++++++++++++++++
7 files changed, 287 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-core/src/exchange-signatures.test.ts b/packages/taler-wallet-core/src/exchange-signatures.test.ts @@ -0,0 +1,58 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + EddsaPublicKeyString, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { timestampProtocolToDb, WalletExchangeSignkeys } from "./db-common.js"; +import { exchangeSigningKeyIsUsable } from "./exchange-signatures.js"; + +const t = (seconds: number) => TalerProtocolTimestamp.fromSeconds(seconds); + +function signKey(pub: string): WalletExchangeSignkeys { + return { + exchangeDetailsRowId: 1, + signkeyPub: pub as EddsaPublicKeyString, + stampStart: timestampProtocolToDb(t(1_000)), + stampExpire: timestampProtocolToDb(t(2_000)), + stampEnd: timestampProtocolToDb(t(3_000)), + masterSig: "master-signature", + } as WalletExchangeSignkeys; +} + +test("exchange response keys must match and cover the signing time", () => { + const at = AbsoluteTime.fromProtocolTimestamp(t(1_500)); + assert.strictEqual( + exchangeSigningKeyIsUsable([signKey("key")], "key", at), + true, + ); + assert.strictEqual( + exchangeSigningKeyIsUsable([signKey("other")], "key", at), + false, + ); + assert.strictEqual( + exchangeSigningKeyIsUsable( + [signKey("key")], + "key", + AbsoluteTime.fromProtocolTimestamp(t(10_000)), + ), + false, + ); +}); diff --git a/packages/taler-wallet-core/src/exchange-signatures.ts b/packages/taler-wallet-core/src/exchange-signatures.ts @@ -0,0 +1,150 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountString, + Duration, + EddsaPublicKeyString, + ExchangePurseStatus, + TalerError, + TalerErrorCode, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { + timestampProtocolFromDb, + WalletExchangeSignkeys, +} from "./db-common.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const signingTimeTolerance = Duration.fromSpec({ hours: 1 }); + +export function exchangeSigningKeyIsUsable( + signKeys: WalletExchangeSignkeys[], + exchangePub: EddsaPublicKeyString, + signedAt: AbsoluteTime, +): boolean { + const latestStart = AbsoluteTime.addDuration(signedAt, signingTimeTolerance); + const earliestExpiry = AbsoluteTime.subtractDuraction( + signedAt, + signingTimeTolerance, + ); + return signKeys.some( + (key) => + key.signkeyPub === exchangePub && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(key.stampStart), + ), + latestStart, + ) <= 0 && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(key.stampExpire), + ), + earliestExpiry, + ) > 0, + ); +} + +async function isKnownExchangeSigningKey( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + exchangePub: EddsaPublicKeyString, + signedAt: AbsoluteTime, +): Promise<boolean> { + return await wex.runWalletDbTx(async (tx) => { + const details = await tx.getExchangeDetails(exchangeBaseUrl); + if (details?.rowId === undefined) { + return false; + } + const signKeys = await tx.getExchangeSignKeysByDetailsRowId(details.rowId); + return exchangeSigningKeyIsUsable(signKeys, exchangePub, signedAt); + }); +} + +function invalidExchangeSignature(message: string): TalerError { + return TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + message, + ); +} + +export async function requireValidExchangePurseStatus( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + status: ExchangePurseStatus, +): Promise<void> { + const [knownKey, signatureResult] = await Promise.all([ + isKnownExchangeSigningKey( + wex, + exchangeBaseUrl, + status.exchange_pub, + AbsoluteTime.now(), + ), + wex.cryptoApi.isValidPurseStatus({ + balance: status.balance, + depositTimestamp: status.deposit_timestamp, + mergeTimestamp: status.merge_timestamp, + exchangePub: status.exchange_pub, + exchangeSig: status.exchange_sig, + }), + ]); + if (!knownKey || !signatureResult.valid) { + throw invalidExchangeSignature( + "exchange returned an invalid purse status signature", + ); + } +} + +export async function requireValidExchangeRefundConfirmation( + wex: WalletExecutionContext, + args: { + exchangeBaseUrl: string; + contractTermsHash: string; + coinPub: string; + merchantPub: string; + rtransactionId: number; + refundAmount: AmountString; + executionTime: TalerProtocolTimestamp; + exchangePub: string; + exchangeSig: string; + }, +): Promise<void> { + const [knownKey, signatureResult] = await Promise.all([ + isKnownExchangeSigningKey( + wex, + args.exchangeBaseUrl, + args.exchangePub, + AbsoluteTime.fromProtocolTimestamp(args.executionTime), + ), + wex.cryptoApi.isValidRefundConfirmation({ + contractTermsHash: args.contractTermsHash, + coinPub: args.coinPub, + merchantPub: args.merchantPub, + rtransactionId: args.rtransactionId, + refundAmount: args.refundAmount, + exchangePub: args.exchangePub, + exchangeSig: args.exchangeSig, + }), + ]); + if (!knownKey || !signatureResult.valid) { + throw invalidExchangeSignature( + "exchange returned an invalid refund confirmation signature", + ); + } +} diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -161,6 +161,7 @@ import { getScopeForAllCoins, getScopeForAllExchanges, } from "./exchanges.js"; +import { requireValidExchangeRefundConfirmation } from "./exchange-signatures.js"; import { instantiateTemplateRaw } from "./pay-template.js"; import { runWithMaybeProgressContext } from "./progress.js"; import { @@ -5030,6 +5031,37 @@ async function storeRefunds( const currency = Amounts.currencyOf(amountRaw); + const successfulRefunds = refunds.filter( + (refund) => refund.type === "success", + ); + if (successfulRefunds.length > 0) { + const coins = await wex.runWalletDbTx((tx) => + tx.getCoinsByPubs(successfulRefunds.map((refund) => refund.coin_pub)), + ); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + for (const refund of successfulRefunds) { + const coin = coinsByPub.get(refund.coin_pub); + if (!coin) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + `merchant returned a refund for unknown coin ${refund.coin_pub}`, + ); + } + await requireValidExchangeRefundConfirmation(wex, { + exchangeBaseUrl: coin.exchangeBaseUrl, + contractTermsHash: download.contractTermsHash, + coinPub: refund.coin_pub, + merchantPub: download.contractTerms.merchant_pub, + rtransactionId: refund.rtransaction_id, + refundAmount: refund.refund_amount, + executionTime: refund.execution_time, + exchangePub: refund.exchange_pub, + exchangeSig: refund.exchange_sig, + }); + } + } + const result = await wex.runWalletDbTx(async (tx) => { const [myPurchase, h] = await ctx.getRecordHandle(tx); if (!myPurchase) { diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -79,6 +79,7 @@ import { } from "./db-common.js"; import {} from "./db-indexeddb.js"; import { WalletDbTransaction } from "./dbtx.js"; +import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; import { BalanceThresholdCheckResult, checkIncomingAmountLegalUnderKycBalanceThreshold, @@ -555,6 +556,12 @@ async function processPendingReady( assertUnreachable(resp); } + await requireValidExchangePurseStatus( + wex, + pullIni.exchangeBaseUrl, + resp.body, + ); + if (!isPurseDeposited(resp.body)) { logger.info("purse not ready yet (no deposit)"); return TaskRunResult.longpollReturnedPending(); @@ -688,6 +695,11 @@ async function processPeerPullCreditAbortingDeletePurse( const statusResp = await exchangeClient.getPurseStatusAtMerge(pursePub); switch (statusResp.case) { case "ok": + await requireValidExchangePurseStatus( + wex, + peerPullIni.exchangeBaseUrl, + statusResp.body, + ); // If the payer won the race with our abort, continue through the // normal reserve-withdrawal path instead of retrying DELETE. completionStatus = statusAfterPullCreditDeleteConflict( diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -107,6 +107,7 @@ import { } from "./transactions.js"; import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; import { WalletDbTransaction } from "./dbtx.js"; +import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; const logger = new Logger("pay-peer-pull-debit.ts"); @@ -538,6 +539,12 @@ async function processPeerPullDebitDialogProposed( assertUnreachable(resp); } + await requireValidExchangePurseStatus( + wex, + pullIni.exchangeBaseUrl, + resp.body, + ); + if (isPurseDeposited(resp.body)) { logger.info("purse completed by another wallet"); await ctx.wex.runWalletDbTx(async (tx) => { @@ -1051,6 +1058,8 @@ async function internalPreparePeerPullDebit( assertUnreachable(resp); } + await requireValidExchangePurseStatus(wex, exchangeBaseUrl, resp.body); + if (isPurseDeposited(resp.body)) { logger.info("purse completed by another wallet"); throw TalerError.fromDetail( diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -123,6 +123,7 @@ import { waitWithdrawalFinal, } from "./withdraw.js"; import { WalletDbTransaction } from "./dbtx.js"; +import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; const logger = new Logger("pay-peer-push-credit.ts"); @@ -653,6 +654,7 @@ async function internalPreparePeerPushCredit( assertUnreachable(resp); } + await requireValidExchangePurseStatus(wex, exchangeBaseUrl, resp.body); const purseStatus = resp.body; logger.info( @@ -1147,6 +1149,12 @@ async function processPeerPushDebitDialogProposed( assertUnreachable(resp); } + await requireValidExchangePurseStatus( + wex, + pullIni.exchangeBaseUrl, + resp.body, + ); + if (isPurseMerged(resp.body)) { logger.info("purse completed by another wallet"); await ctx.wex.runWalletDbTx(async (tx) => { diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -89,6 +89,7 @@ import { timestampProtocolToDb, } from "./db-common.js"; import { WalletDbTransaction } from "./dbtx.js"; +import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; import { fetchFreshExchange, getExchangeDetailsInTx, @@ -982,6 +983,11 @@ async function processPeerPushDebitCreateReserve( const resp = await exchangeClient.getPurseStatusAtDeposit(pursePub); switch (resp.case) { case "ok": + await requireValidExchangePurseStatus( + wex, + peerPushInitiation.exchangeBaseUrl, + resp.body, + ); await wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { @@ -1058,6 +1064,13 @@ async function processPeerPushDebitDeletePurse( // which: if the recipient merged it, the money is theirs and the coins // must not be refreshed. const statusResp = await exchangeClient.getPurseStatusAtMerge(pursePub); + if (statusResp.case === "ok") { + await requireValidExchangePurseStatus( + wex, + exchangeBaseUrl, + statusResp.body, + ); + } if (statusResp.case === "ok" && isPurseMerged(statusResp.body)) { await wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); @@ -1145,6 +1158,11 @@ async function processPeerPushDebitReady( switch (resp.case) { case "ok": { + await requireValidExchangePurseStatus( + wex, + peerPushInitiation.exchangeBaseUrl, + resp.body, + ); if (!isPurseMerged(resp.body)) { return TaskRunResult.longpollReturnedPending(); } else {