taler-typescript-core

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

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:
Mpackages/taler-util/src/http-client/exchange-client.ts | 49++++++++++++++++++++++++++++++++++++++-----------
Apackages/taler-util/src/http-client/exchange-refund.test.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/deposits.test.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackages/taler-wallet-core/src/exchange-signatures.ts | 37+++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/refresh.ts | 17++++++++++++++---
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) {