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:
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 {