taler-typescript-core

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

commit d9401613fd72554b2c16ef1b116595fcb8b5789f
parent 1b6e8d103ff34154ba98af15e5ad3873156ae21c
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:49 +0200

wallet-core: authenticate purse deposit confirmations

Diffstat:
Mpackages/taler-util/src/http-client/exchange-client.ts | 7++++---
Mpackages/taler-util/src/types-taler-exchange.test.ts | 19+++++++++++++++++++
Mpackages/taler-util/src/types-taler-exchange.ts | 17+++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 41+++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchange-signatures.test.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchange-signatures.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 13++++++++++++-
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 21++++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 21++++++++++++++++++++-
10 files changed, 282 insertions(+), 6 deletions(-)

diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -91,6 +91,7 @@ import { PurseConflict, PurseConflictPartial, PurseCreateSuccessResponse, + PurseDepositSuccessResponse, RecoupConfirmation, RecoupRefreshRequest, RecoupRequest, @@ -125,6 +126,7 @@ import { codecForPurseConflict, codecForPurseConflictPartial, codecForPurseCreateSuccessResponse, + codecForPurseDepositSuccessResponse, codecForRecoupConfirmation, codecForTackTransactionAccepted, codecForTackTransactionWired, @@ -614,7 +616,7 @@ export class TalerExchangeHttpClient { pursePub: string, body: ExchangePurseDeposits, ): Promise< - | OperationOk<void> + | OperationOk<PurseDepositSuccessResponse> | OperationAlternative<HttpStatusCode.Conflict, PurseConflict> | OperationFail<HttpStatusCode.Forbidden> | OperationFail<HttpStatusCode.NotFound> @@ -626,8 +628,7 @@ export class TalerExchangeHttpClient { }); switch (resp.status) { case HttpStatusCode.Ok: - // FIXME: parse PurseDepositSuccessResponse - return opSuccessFromHttp(resp, codecForAny()); + return opSuccessFromHttp(resp, codecForPurseDepositSuccessResponse()); case HttpStatusCode.Conflict: return opKnownAlternativeHttpFailure( resp, diff --git a/packages/taler-util/src/types-taler-exchange.test.ts b/packages/taler-util/src/types-taler-exchange.test.ts @@ -20,7 +20,9 @@ import { codecForAuditor, codecForEventCounter, codecForExchangeKeysResponse, + codecForPurseCreateSuccessResponse, } from "./types-taler-exchange.js"; +import { encodeCrock } from "./taler-crypto.js"; /** * A /keys response captured from a live exchange (protocol 37:0:3), reduced @@ -221,6 +223,23 @@ test("codecForAuditor accepts the human-readable auditor name", () => { assert.strictEqual(auditor.auditor_name, "Example Auditor"); }); +test("purse-deposit success retains every signed field", () => { + const response = { + total_deposited: "TESTKUDOS:3", + purse_value_after_fees: "TESTKUDOS:5", + exchange_timestamp: { t_s: 1_700_000_000 }, + purse_expiration: { t_s: 1_700_003_600 }, + h_contract_terms: encodeCrock(new Uint8Array(64).fill(1)), + exchange_pub: encodeCrock(new Uint8Array(32).fill(2)), + exchange_sig: encodeCrock(new Uint8Array(64).fill(3)), + }; + + assert.deepStrictEqual( + codecForPurseCreateSuccessResponse().decode(response), + response, + ); +}); + test("codecForExchangeKeysResponse rejects a /keys document that is missing required fields", (t) => { // This is the exact shape that used to decode successfully, leaving // denominations, master_public_key and signkeys undefined behind a type diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -1438,9 +1438,18 @@ export interface PurseCreateSuccessResponse { // Total amount deposited into the purse so far (without fees). total_deposited: AmountString; + // Total value the purse must contain after deposit fees. + purse_value_after_fees: AmountString; + // Time at the exchange. exchange_timestamp: Timestamp; + // Time when an incomplete purse expires. + purse_expiration: Timestamp; + + // Hash of the private contract terms bound by the signature. + h_contract_terms: HashCodeString; + // EdDSA signature of the exchange affirming the payment, // of purpose TALER_SIGNATURE_PURSE_DEPOSIT_CONFIRMED // over a TALER_PurseDepositConfirmedSignaturePS. @@ -1456,11 +1465,19 @@ export const codecForPurseCreateSuccessResponse = (): Codec<PurseCreateSuccessResponse> => buildCodecForObject<PurseCreateSuccessResponse>() .property("total_deposited", codecForAmountString()) + .property("purse_value_after_fees", codecForAmountString()) .property("exchange_timestamp", codecForTimestamp) + .property("purse_expiration", codecForTimestamp) + .property("h_contract_terms", codecForString()) .property("exchange_sig", codecForEddsaSignature()) .property("exchange_pub", codecForEddsaPublicKey()) .build("PurseCreateSuccessResponse"); +export type PurseDepositSuccessResponse = PurseCreateSuccessResponse; + +export const codecForPurseDepositSuccessResponse = + codecForPurseCreateSuccessResponse; + /** * Doc name: api-exchange/MergeConflict */ diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -231,6 +231,51 @@ test("purse status signature binds timestamps and balance", async () => { ); }); +test("purse-deposit confirmation binds the complete signed response", async () => { + const exchangeTimestamp = t(1234); + const purseExpiration = t(2234); + const purseValueAfterFees = "TESTKUDOS:5" as AmountString; + const totalDeposited = "TESTKUDOS:3" as AmountString; + const pursePub = encodeCrock(new Uint8Array(32).fill(4)); + const contractTermsHash = encodeCrock(new Uint8Array(64).fill(5)); + const sigBlob = buildSigPS( + TalerSignaturePurpose.EXCHANGE_CONFIRM_PURSE_CREATION, + ) + .put(timestampRoundedToBuffer(exchangeTimestamp)) + .put(timestampRoundedToBuffer(purseExpiration)) + .put(bufferFromAmount(Amounts.parseOrThrow(purseValueAfterFees))) + .put(bufferFromAmount(Amounts.parseOrThrow(totalDeposited))) + .put(decodeCrock(pursePub)) + .put(decodeCrock(contractTermsHash)) + .build(); + const exchangePub = encodeCrock(signKey.eddsaPub) as EddsaPublicKeyString; + const exchangeSig = encodeCrock( + eddsaSign(sigBlob, signKey.eddsaPriv), + ) as EddsaSignatureString; + const request = { + exchangeTimestamp, + purseExpiration, + purseValueAfterFees, + totalDeposited, + pursePub, + contractTermsHash, + exchangePub, + exchangeSig, + }; + + assert.deepStrictEqual( + await nativeCryptoR.isValidPurseDepositConfirmation(nativeCryptoR, request), + { valid: true }, + ); + assert.deepStrictEqual( + await nativeCryptoR.isValidPurseDepositConfirmation(nativeCryptoR, { + ...request, + totalDeposited: "TESTKUDOS:4", + }), + { valid: false }, + ); +}); + test("purse merge signature binds the purse, reserve and timestamp", async () => { const mergeTimestamp = t(1_234); const pursePub = encodeCrock(new Uint8Array(32).fill(4)); diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -270,6 +270,10 @@ export interface TalerCryptoInterface { req: PurseStatusValidationRequest, ): Promise<ValidationResult>; + isValidPurseDepositConfirmation( + req: PurseDepositConfirmationValidationRequest, + ): Promise<ValidationResult>; + isValidPurseMergeSignature( req: PurseMergeSignatureValidationRequest, ): Promise<ValidationResult>; @@ -490,6 +494,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<ValidationResult> { throw new Error("Function not implemented."); }, + isValidPurseDepositConfirmation: function ( + req: PurseDepositConfirmationValidationRequest, + ): Promise<ValidationResult> { + throw new Error("Function not implemented."); + }, isValidPurseMergeSignature: function ( req: PurseMergeSignatureValidationRequest, ): Promise<ValidationResult> { @@ -940,6 +949,17 @@ export interface PurseStatusValidationRequest { exchangeSig: EddsaSignatureString; } +export interface PurseDepositConfirmationValidationRequest { + exchangeTimestamp: TalerProtocolTimestamp; + purseExpiration: TalerProtocolTimestamp; + purseValueAfterFees: AmountString; + totalDeposited: AmountString; + pursePub: EddsaPublicKeyString; + contractTermsHash: HashCodeString; + exchangePub: EddsaPublicKeyString; + exchangeSig: EddsaSignatureString; +} + export interface PurseMergeSignatureValidationRequest { mergeTimestamp: TalerProtocolTimestamp; pursePub: EddsaPublicKeyString; @@ -1563,6 +1583,27 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { }; }, + async isValidPurseDepositConfirmation( + tci: TalerCryptoInterfaceR, + req: PurseDepositConfirmationValidationRequest, + ): Promise<ValidationResult> { + const p = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_PURSE_CREATION) + .put(timestampRoundedToBuffer(req.exchangeTimestamp)) + .put(timestampRoundedToBuffer(req.purseExpiration)) + .put(bufferFromAmount(Amounts.parseOrThrow(req.purseValueAfterFees))) + .put(bufferFromAmount(Amounts.parseOrThrow(req.totalDeposited))) + .put(decodeCrock(req.pursePub)) + .put(decodeCrock(req.contractTermsHash)) + .build(); + return { + valid: eddsaVerify( + p, + decodeCrock(req.exchangeSig), + decodeCrock(req.exchangePub), + ), + }; + }, + async isValidPurseMergeSignature( tci: TalerCryptoInterfaceR, req: PurseMergeSignatureValidationRequest, diff --git a/packages/taler-wallet-core/src/exchange-signatures.test.ts b/packages/taler-wallet-core/src/exchange-signatures.test.ts @@ -27,6 +27,7 @@ import { timestampProtocolToDb, WalletExchangeSignkeys } from "./db-common.js"; import { exchangeSigningKeyIsUsable, requireValidDirectExchangeRefundConfirmation, + requireValidExchangePurseDepositConfirmation, requireValidExchangePurseStatus, } from "./exchange-signatures.js"; @@ -93,6 +94,9 @@ function verificationContext(args: { async isValidPurseStatus() { return { valid: args.cryptoValid }; }, + async isValidPurseDepositConfirmation() { + return { valid: args.cryptoValid }; + }, async isValidRefundConfirmation() { return { valid: args.cryptoValid }; }, @@ -179,3 +183,53 @@ test("direct refund confirmation requires signature and exchange key", async () /invalid refund confirmation signature/, ); }); + +test("purse-deposit confirmation requires contract binding and exchange key", async () => { + const args = { + exchangeBaseUrl: "https://exchange.example/", + pursePub: "purse-public-key", + contractTermsHash: "contract-hash", + purseValueAfterFees: "TESTKUDOS:5" as AmountString, + purseExpiration: TalerProtocolTimestamp.fromSeconds(2_000_000_000), + response: { + total_deposited: "TESTKUDOS:3" as AmountString, + purse_value_after_fees: "TESTKUDOS:5" as AmountString, + exchange_timestamp: TalerProtocolTimestamp.now(), + purse_expiration: TalerProtocolTimestamp.fromSeconds(2_000_000_000), + h_contract_terms: "contract-hash", + exchange_pub: "response-key", + exchange_sig: "exchange-signature", + }, + }; + + await assert.doesNotReject( + requireValidExchangePurseDepositConfirmation( + verificationContext({ cryptoValid: true, knownPub: "response-key" }), + args, + ), + ); + await assert.rejects( + requireValidExchangePurseDepositConfirmation( + verificationContext({ cryptoValid: false, knownPub: "response-key" }), + args, + ), + /invalid purse-deposit confirmation signature/, + ); + await assert.rejects( + requireValidExchangePurseDepositConfirmation( + verificationContext({ cryptoValid: true, knownPub: "different-key" }), + args, + ), + /invalid purse-deposit confirmation signature/, + ); + await assert.rejects( + requireValidExchangePurseDepositConfirmation( + verificationContext({ cryptoValid: true, knownPub: "response-key" }), + { + ...args, + purseValueAfterFees: "TESTKUDOS:6", + }, + ), + /does not match the purse contract/, + ); +}); diff --git a/packages/taler-wallet-core/src/exchange-signatures.ts b/packages/taler-wallet-core/src/exchange-signatures.ts @@ -17,12 +17,14 @@ import { AbsoluteTime, AmountString, + Amounts, BatchDepositSuccess, Duration, EddsaPublicKeyString, ExchangePurseStatus, ExchangeRefundSuccessResponse, HashCodeString, + PurseDepositSuccessResponse, TalerError, TalerErrorCode, TalerProtocolTimestamp, @@ -115,6 +117,54 @@ export async function requireValidExchangePurseStatus( } } +export async function requireValidExchangePurseDepositConfirmation( + wex: WalletExecutionContext, + args: { + exchangeBaseUrl: string; + pursePub: string; + contractTermsHash: HashCodeString; + purseValueAfterFees: AmountString; + purseExpiration: TalerProtocolTimestamp; + response: PurseDepositSuccessResponse; + }, +): Promise<void> { + if ( + args.response.h_contract_terms !== args.contractTermsHash || + Amounts.cmp( + args.response.purse_value_after_fees, + args.purseValueAfterFees, + ) !== 0 || + args.response.purse_expiration.t_s !== args.purseExpiration.t_s + ) { + throw invalidExchangeSignature( + "exchange purse-deposit confirmation does not match the purse contract", + ); + } + const [knownKey, signatureResult] = await Promise.all([ + isKnownExchangeSigningKey( + wex, + args.exchangeBaseUrl, + args.response.exchange_pub, + AbsoluteTime.fromProtocolTimestamp(args.response.exchange_timestamp), + ), + wex.cryptoApi.isValidPurseDepositConfirmation({ + exchangeTimestamp: args.response.exchange_timestamp, + purseExpiration: args.response.purse_expiration, + purseValueAfterFees: args.response.purse_value_after_fees, + totalDeposited: args.response.total_deposited, + pursePub: args.pursePub, + contractTermsHash: args.response.h_contract_terms, + exchangePub: args.response.exchange_pub, + exchangeSig: args.response.exchange_sig, + }), + ]); + if (!knownKey || !signatureResult.valid) { + throw invalidExchangeSignature( + "exchange returned an invalid purse-deposit confirmation signature", + ); + } +} + export async function requireValidExchangeRefundConfirmation( wex: WalletExecutionContext, args: { 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,7 +79,10 @@ import { } from "./db-common.js"; import {} from "./db-indexeddb.js"; import { WalletDbTransaction } from "./dbtx.js"; -import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; +import { + requireValidExchangePurseDepositConfirmation, + requireValidExchangePurseStatus, +} from "./exchange-signatures.js"; import { BalanceThresholdCheckResult, checkIncomingAmountLegalUnderKycBalanceThreshold, @@ -916,6 +919,14 @@ async function processPeerPullCreditCreatePurse( switch (resp.case) { case "ok": + await requireValidExchangePurseDepositConfirmation(wex, { + exchangeBaseUrl: pullIni.exchangeBaseUrl, + pursePub: pullIni.pursePub, + contractTermsHash: pullIni.contractTermsHash, + purseValueAfterFees: pullIni.amount, + purseExpiration, + response: resp.body, + }); break; case HttpStatusCode.UnavailableForLegalReasons: { logger.info(`kyc uuid response: ${j2s(resp.body)}`); 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,7 +107,10 @@ import { } from "./transactions.js"; import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; import { WalletDbTransaction } from "./dbtx.js"; -import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; +import { + requireValidExchangePurseDepositConfirmation, + requireValidExchangePurseStatus, +} from "./exchange-signatures.js"; const logger = new Logger("pay-peer-pull-debit.ts"); @@ -641,6 +644,14 @@ async function processPeerPullDebitPendingDeposit( } const exchangeClient = walletExchangeClient(peerPullInc.exchangeBaseUrl, wex); + const contractTerms = await wex.runWalletDbTx(async (tx) => + tx.getContractTerms(peerPullInc.contractTermsHash), + ); + checkDbInvariant( + !!contractTerms, + `no contract terms for peer pull debit ${peerPullInc.peerPullDebitId}`, + ); + // FIXME: We could skip batches that we've already submitted. const coins = await queryCoinInfosForSelection(wex, coinSel); @@ -675,6 +686,14 @@ async function processPeerPullDebitPendingDeposit( ); switch (resp.case) { case "ok": + await requireValidExchangePurseDepositConfirmation(wex, { + exchangeBaseUrl, + pursePub, + contractTermsHash: peerPullInc.contractTermsHash, + purseValueAfterFees: peerPullInc.amount, + purseExpiration: contractTerms.contractTermsRaw.purse_expiration, + response: resp.body, + }); continue; case HttpStatusCode.Gone: { await ctx.purseGoneTransaction(peerPullInc.status); 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,7 +89,10 @@ import { timestampProtocolToDb, } from "./db-common.js"; import { WalletDbTransaction } from "./dbtx.js"; -import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; +import { + requireValidExchangePurseDepositConfirmation, + requireValidExchangePurseStatus, +} from "./exchange-signatures.js"; import { fetchFreshExchange, getExchangeDetailsInTx, @@ -916,6 +919,14 @@ async function processPeerPushDebitCreateReserve( ); switch (resp.case) { case "ok": + await requireValidExchangePurseDepositConfirmation(wex, { + exchangeBaseUrl, + pursePub: peerPushInitiation.pursePub, + contractTermsHash, + purseValueAfterFees: purseAmount, + purseExpiration: timestampProtocolFromDb(purseExpiration), + response: resp.body, + }); // Possibly on to the next batch. continue; case HttpStatusCode.Forbidden: @@ -943,6 +954,14 @@ async function processPeerPushDebitCreateReserve( ); switch (resp.case) { case "ok": + await requireValidExchangePurseDepositConfirmation(wex, { + exchangeBaseUrl, + pursePub: peerPushInitiation.pursePub, + contractTermsHash, + purseValueAfterFees: purseAmount, + purseExpiration: timestampProtocolFromDb(purseExpiration), + response: resp.body, + }); // Possibly on to the next batch. continue; case HttpStatusCode.Gone: