commit 208df6ba5543f239b68e0ca1af519dfdc09e0ffb
parent f165236f8702313ea7558d637daccd54cc82b087
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:48 +0200
wallet-core: authenticate direct refund recovery
Diffstat:
6 files changed, 237 insertions(+), 41 deletions(-)
diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts
@@ -74,6 +74,7 @@ import {
ExchangePurseStatus,
ExchangeRefreshRevealRequestV2,
ExchangeRefundRequest,
+ ExchangeRefundSuccessResponse,
ExchangeReservePurseRequest,
ExchangeRevealMeltResponseV2,
ExchangeTransferList,
@@ -114,6 +115,7 @@ import {
codecForExchangeMergeSuccessResponse,
codecForExchangePurseStatus,
codecForExchangeRevealMeltResponseV2,
+ codecForExchangeRefundSuccessResponse,
codecForExchangeTransferList,
codecForExchangeWithdrawResponse,
codecForKycProcessClientInformation,
@@ -283,7 +285,7 @@ export class TalerExchangeHttpClient {
* https://docs.taler.net/core/api-exchange.html#get--config
*
*/
- async getConfig(){
+ async getConfig() {
const resp = await this.fetch("config");
switch (resp.status) {
case HttpStatusCode.Ok:
@@ -307,7 +309,9 @@ export class TalerExchangeHttpClient {
*/
async getKeys(
opts: { noCache?: boolean; lastIssueDate?: number } = {},
- ): Promise<OperationOk<ExchangeKeysResponse> | OperationFail<HttpStatusCode.NotFound>> {
+ ): Promise<
+ OperationOk<ExchangeKeysResponse> | OperationFail<HttpStatusCode.NotFound>
+ > {
const headers: Record<string, string> = {};
if (opts.noCache) {
headers["cache-control"] = "no-cache";
@@ -1294,8 +1298,14 @@ export class TalerExchangeHttpClient {
account?: PaytoHash;
active?: boolean;
} = {},
- ): Promise<OperationOk<LegitimizationMeasuresList> | OperationFail<HttpStatusCode.NotFound>> {
- const url = new URL(`aml/${pathSegment(officer.id)}/legitimizations`, this.baseUrl);
+ ): Promise<
+ | OperationOk<LegitimizationMeasuresList>
+ | OperationFail<HttpStatusCode.NotFound>
+ > {
+ const url = new URL(
+ `aml/${pathSegment(officer.id)}/legitimizations`,
+ this.baseUrl,
+ );
addPaginationParams(url, params);
if (params.account !== undefined) {
@@ -1343,7 +1353,10 @@ export class TalerExchangeHttpClient {
| HttpStatusCode.Conflict
>
> {
- const url = new URL(`aml/${pathSegment(auth.id)}/attributes/${pathSegment(account)}`, this.baseUrl);
+ const url = new URL(
+ `aml/${pathSegment(auth.id)}/attributes/${pathSegment(account)}`,
+ this.baseUrl,
+ );
addPaginationParams(url, params);
const resp = await this.fetch(url, {
@@ -1386,7 +1399,10 @@ export class TalerExchangeHttpClient {
| HttpStatusCode.NotImplemented
>
> {
- const url = new URL(`aml/${pathSegment(auth.id)}/attributes/${pathSegment(account)}`, this.baseUrl);
+ const url = new URL(
+ `aml/${pathSegment(auth.id)}/attributes/${pathSegment(account)}`,
+ this.baseUrl,
+ );
addPaginationParams(url, params);
const resp = await this.fetch(url, {
@@ -1472,7 +1488,10 @@ export class TalerExchangeHttpClient {
| HttpStatusCode.Conflict
>
> {
- const url = new URL(`aml/${pathSegment(auth.id)}/transfers-credit`, this.baseUrl);
+ const url = new URL(
+ `aml/${pathSegment(auth.id)}/transfers-credit`,
+ this.baseUrl,
+ );
addPaginationParams(url, params);
@@ -1523,7 +1542,10 @@ export class TalerExchangeHttpClient {
| HttpStatusCode.Conflict
>
> {
- const url = new URL(`aml/${pathSegment(auth.id)}/transfers-debit`, this.baseUrl);
+ const url = new URL(
+ `aml/${pathSegment(auth.id)}/transfers-debit`,
+ this.baseUrl,
+ );
addPaginationParams(url, params);
@@ -1574,7 +1596,10 @@ export class TalerExchangeHttpClient {
| HttpStatusCode.Conflict
>
> {
- const url = new URL(`aml/${pathSegment(auth.id)}/transfers-kycauth`, this.baseUrl);
+ const url = new URL(
+ `aml/${pathSegment(auth.id)}/transfers-kycauth`,
+ this.baseUrl,
+ );
addPaginationParams(url, params);
@@ -1729,13 +1754,15 @@ export class TalerExchangeHttpClient {
async refundCoin(
coinPub: string,
body: ExchangeRefundRequest,
- ): Promise<OperationOk<undefined> | OperationFail<HttpStatusCode>> {
+ ): Promise<
+ OperationOk<ExchangeRefundSuccessResponse> | OperationFail<HttpStatusCode>
+ > {
const resp = await this.fetch(`coins/${coinPub}/refund`, {
method: "POST",
body,
});
if (resp.status === HttpStatusCode.Ok) {
- return opEmptySuccess(resp);
+ return opSuccessFromHttp(resp, codecForExchangeRefundSuccessResponse());
}
return opKnownHttpFailure(resp.status, resp);
}
diff --git a/packages/taler-util/src/http-client/exchange-refund.test.ts b/packages/taler-util/src/http-client/exchange-refund.test.ts
@@ -0,0 +1,44 @@
+/*
+ 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 assert from "node:assert";
+import { test } from "node:test";
+import { FakeHttpLib, ok } from "../http-fake.js";
+import { encodeCrock } from "../taler-crypto.js";
+import { ExchangeRefundRequest } from "../types-taler-exchange.js";
+import { TalerExchangeHttpClient } from "./exchange-client.js";
+
+test("refundCoin preserves the signed exchange confirmation", async () => {
+ const exchangePub = encodeCrock(new Uint8Array(32));
+ const exchangeSig = encodeCrock(new Uint8Array(64));
+ const http = new FakeHttpLib().on(
+ "POST",
+ "/coins/coin/refund",
+ ok({ exchange_pub: exchangePub, exchange_sig: exchangeSig }),
+ );
+ const client = new TalerExchangeHttpClient("https://exchange.example/", {
+ httpClient: http,
+ });
+
+ const result = await client.refundCoin("coin", {} as ExchangeRefundRequest);
+
+ assert.strictEqual(result.case, "ok");
+ if (result.case !== "ok") return;
+ assert.deepStrictEqual(result.body, {
+ exchange_pub: exchangePub,
+ exchange_sig: exchangeSig,
+ });
+});
diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts
@@ -0,0 +1,43 @@
+/*
+ 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 { HttpStatusCode } from "@gnu-taler/taler-util";
+import assert from "node:assert";
+import { test } from "node:test";
+import { depositRefundStatusIsRetryable } from "./deposits.js";
+
+test("deposit abort retries transient refund responses", () => {
+ assert.strictEqual(
+ depositRefundStatusIsRetryable(HttpStatusCode.RequestTimeout),
+ true,
+ );
+ assert.strictEqual(
+ depositRefundStatusIsRetryable(HttpStatusCode.TooManyRequests),
+ true,
+ );
+ assert.strictEqual(
+ depositRefundStatusIsRetryable(HttpStatusCode.InternalServerError),
+ true,
+ );
+ assert.strictEqual(
+ depositRefundStatusIsRetryable(HttpStatusCode.ServiceUnavailable),
+ true,
+ );
+ assert.strictEqual(
+ depositRefundStatusIsRetryable(HttpStatusCode.Gone),
+ false,
+ );
+});
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -96,6 +96,7 @@ import {
prepareTransferOptionsRaw,
spendCoins,
} from "./common.js";
+import { requireValidDirectExchangeRefundConfirmation } from "./exchange-signatures.js";
import {
DepositElementStatus,
DepositOperationStatus,
@@ -911,37 +912,53 @@ async function refundDepositGroup(
);
const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin]));
+ const makeRefundRequest = async (
+ coinPub: string,
+ refundAmount: AmountString,
+ ): Promise<ExchangeRefundRequest> => {
+ // We use a constant refund transaction ID, since there can only be one
+ // refund for this contract.
+ const rtransactionId = 1;
+ const sig = await wex.cryptoApi.signRefund({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ refundAmount,
+ rtransactionId,
+ });
+ return {
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_pub: depositGroup.merchantPub,
+ merchant_sig: sig.sig,
+ refund_amount: refundAmount,
+ rtransaction_id: rtransactionId,
+ };
+ };
+
for (let i = 0; i < statusPerCoin.length; i++) {
const st = statusPerCoin[i];
switch (st) {
case DepositElementStatus.RefundFailed:
case DepositElementStatus.RefundSuccess:
- case DepositElementStatus.RefundNotFound:
break;
+ case DepositElementStatus.RefundNotFound: {
+ // This request is needed if refresh later discovers that the deposit
+ // raced with the 404. Reconstruct it after retries and restarts
+ // instead of relying on the transient in-memory array.
+ refundReqPerCoin[i] = await makeRefundRequest(
+ payCoinSelection.coinPubs[i],
+ payCoinSelection.coinContributions[i],
+ );
+ break;
+ }
default: {
const coinPub = payCoinSelection.coinPubs[i];
const coinRecord = coinsByPub.get(coinPub);
checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
const coinExchange = coinRecord.exchangeBaseUrl;
const refundAmount = payCoinSelection.coinContributions[i];
- // We use a constant refund transaction ID, since there can
- // only be one refund for this contract.
- const rtid = 1;
- const sig = await wex.cryptoApi.signRefund({
- coinPub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- refundAmount: refundAmount,
- rtransactionId: rtid,
- });
- const refundReq: ExchangeRefundRequest = {
- h_contract_terms: depositGroup.contractTermsHash,
- merchant_pub: depositGroup.merchantPub,
- merchant_sig: sig.sig,
- refund_amount: refundAmount,
- rtransaction_id: rtid,
- };
+ const refundReq = await makeRefundRequest(coinPub, refundAmount);
const exchangeClient = walletExchangeClient(coinExchange, wex);
const refundResp = await exchangeClient.refundCoin(coinPub, refundReq);
logger.info(
@@ -949,7 +966,15 @@ async function refundDepositGroup(
);
let newStatus: DepositElementStatus;
if (refundResp.case === "ok") {
- // FIXME: validate response
+ await requireValidDirectExchangeRefundConfirmation(wex, {
+ exchangeBaseUrl: coinExchange,
+ contractTermsHash: depositGroup.contractTermsHash,
+ coinPub,
+ merchantPub: depositGroup.merchantPub,
+ rtransactionId: refundReq.rtransaction_id,
+ refundAmount,
+ response: refundResp.body,
+ });
newStatus = DepositElementStatus.RefundSuccess;
} else if (refundResp.case === HttpStatusCode.NotFound) {
// Exchange doesn't know about the deposit.
@@ -958,11 +983,11 @@ async function refundDepositGroup(
// so the subsequent refresh request might fail.
newStatus = DepositElementStatus.RefundNotFound;
refundReqPerCoin[i] = refundReq;
+ } else if (depositRefundStatusIsRetryable(refundResp.case)) {
+ return TaskRunResult.backoff();
} else {
- // FIXME: Store problem somewhere!
newStatus = DepositElementStatus.RefundFailed;
}
- // FIXME: Handle case where refund request needs to be tried again
newTxPerCoin[i] = newStatus;
await wex.runWalletDbTx(async (tx) => {
const newDg = await tx.getDepositGroup(depositGroup.depositGroupId);
@@ -1032,6 +1057,14 @@ async function refundDepositGroup(
return TaskRunResult.backoff();
}
+export function depositRefundStatusIsRetryable(status: number): boolean {
+ return (
+ status === HttpStatusCode.RequestTimeout ||
+ status === HttpStatusCode.TooManyRequests ||
+ status >= HttpStatusCode.InternalServerError
+ );
+}
+
/**
* Check whether the refresh associated with the
* aborting deposit group is done.
@@ -1054,7 +1087,7 @@ async function waitForRefreshOnDepositGroup(
await genericWaitForState(wex, {
async checkState() {
return await wex.runWalletDbTx(async (tx) => {
- const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId);
+ const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId);
return (
classifyRecoveryRefresh(refreshGroup?.operationStatus) !== "pending"
);
@@ -1826,14 +1859,15 @@ async function getBatchDepositTotalWithoutFee(
const coinRecords = await tx.getCoinsByPubs(
coins.map((coin) => coin.coin_pub),
);
- const coinsByPub = new Map(
- coinRecords.map((coin) => [coin.coinPub, coin]),
- );
+ const coinsByPub = new Map(coinRecords.map((coin) => [coin.coinPub, coin]));
const denoms = await getDenomInfos(wex, tx, coinRecords);
const netContributions: AmountJson[] = [];
for (const depositCoin of coins) {
const coin = coinsByPub.get(depositCoin.coin_pub);
- checkDbInvariant(!!coin, `deposit coin ${depositCoin.coin_pub} not found`);
+ checkDbInvariant(
+ !!coin,
+ `deposit coin ${depositCoin.coin_pub} not found`,
+ );
const denom = denoms.get(denomRefKey(coin));
checkDbInvariant(
!!denom,
diff --git a/packages/taler-wallet-core/src/exchange-signatures.ts b/packages/taler-wallet-core/src/exchange-signatures.ts
@@ -21,6 +21,7 @@ import {
Duration,
EddsaPublicKeyString,
ExchangePurseStatus,
+ ExchangeRefundSuccessResponse,
HashCodeString,
TalerError,
TalerErrorCode,
@@ -152,6 +153,42 @@ export async function requireValidExchangeRefundConfirmation(
}
}
+export async function requireValidDirectExchangeRefundConfirmation(
+ wex: WalletExecutionContext,
+ args: {
+ exchangeBaseUrl: string;
+ contractTermsHash: string;
+ coinPub: string;
+ merchantPub: string;
+ rtransactionId: number;
+ refundAmount: AmountString;
+ response: ExchangeRefundSuccessResponse;
+ },
+): Promise<void> {
+ const [knownKey, signatureResult] = await Promise.all([
+ isKnownExchangeSigningKey(
+ wex,
+ args.exchangeBaseUrl,
+ args.response.exchange_pub,
+ AbsoluteTime.now(),
+ ),
+ wex.cryptoApi.isValidRefundConfirmation({
+ contractTermsHash: args.contractTermsHash,
+ coinPub: args.coinPub,
+ merchantPub: args.merchantPub,
+ rtransactionId: args.rtransactionId,
+ refundAmount: args.refundAmount,
+ exchangePub: args.response.exchange_pub,
+ exchangeSig: args.response.exchange_sig,
+ }),
+ ]);
+ if (!knownKey || !signatureResult.valid) {
+ throw invalidExchangeSignature(
+ "exchange returned an invalid refund confirmation signature",
+ );
+ }
+}
+
export async function requireValidExchangeDepositConfirmation(
wex: WalletExecutionContext,
args: {
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -90,6 +90,7 @@ import {
TaskRunResultType,
TransactionContext,
} from "./common.js";
+import { requireValidDirectExchangeRefundConfirmation } from "./exchange-signatures.js";
import { RefreshNewDenomInfo } from "./crypto/cryptoTypes.js";
import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js";
import {
@@ -1038,13 +1039,14 @@ async function refreshMelt(
) > 0,
);
});
- const { valid: signatureValid } =
- await wex.cryptoApi.isValidMeltConfirmation({
+ const { valid: signatureValid } = await wex.cryptoApi.isValidMeltConfirmation(
+ {
refreshCommitment: derived.hash,
norevealIndex,
exchangePub: meltResponse.exchange_pub,
exchangeSig: meltResponse.exchange_sig,
- });
+ },
+ );
if (!signingKeyKnown || !signatureValid) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
@@ -1180,6 +1182,15 @@ async function handleRefreshMeltConflict(
refundReq,
);
if (refundResp.case === "ok") {
+ await requireValidDirectExchangeRefundConfirmation(ctx.wex, {
+ exchangeBaseUrl: oldCoin.exchangeBaseUrl,
+ contractTermsHash: refundReq.h_contract_terms,
+ coinPub: oldCoin.coinPub,
+ merchantPub: refundReq.merchant_pub,
+ rtransactionId: refundReq.rtransaction_id,
+ refundAmount: refundReq.refund_amount,
+ response: refundResp.body,
+ });
await ctx.wex.runWalletDbTx(async (tx) => {
const rg = await tx.getRefreshGroup(refreshGroup.refreshGroupId);
if (!rg || rg.operationStatus != RefreshOperationStatus.Pending) {