taler-typescript-core

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

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

wallet-core: authenticate purse merge conflicts

Diffstat:
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 38++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 35+++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 30++++++++++++++++++++++++++++--
3 files changed, 101 insertions(+), 2 deletions(-)

diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -30,12 +30,14 @@ import { EddsaSignatureString, ExchangeSignKeyJson, GlobalFees, + hashTruncate32, MerchantContractOutputType, MerchantContractTermsV1, TalerProtocolDuration, TalerProtocolTimestamp, TalerSignaturePurpose, timestampRoundedToBuffer, + stringToBytes, } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; @@ -228,6 +230,42 @@ test("purse status signature binds timestamps and balance", async () => { ); }); +test("purse merge signature binds the purse, reserve and timestamp", async () => { + const mergeTimestamp = t(1_234); + const pursePub = encodeCrock(new Uint8Array(32).fill(4)); + const reservePayto = + "payto://taler-reserve/exchange.example/RESERVE-PUBLIC-KEY"; + const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE) + .put(timestampRoundedToBuffer(mergeTimestamp)) + .put(decodeCrock(pursePub)) + .put(hashTruncate32(stringToBytes(reservePayto + "\0"))) + .build(); + const mergePub = encodeCrock(signKey.eddsaPub) as EddsaPublicKeyString; + const mergeSig = encodeCrock( + eddsaSign(sigBlob, signKey.eddsaPriv), + ) as EddsaSignatureString; + const request = { + mergeTimestamp, + pursePub, + reservePayto, + mergePub, + mergeSig, + }; + + assert.deepStrictEqual( + await nativeCryptoR.isValidPurseMergeSignature(nativeCryptoR, request), + { valid: true }, + ); + assert.deepStrictEqual( + await nativeCryptoR.isValidPurseMergeSignature(nativeCryptoR, { + ...request, + reservePayto: + "payto://taler-reserve/exchange.example/OTHER-RESERVE-PUBLIC-KEY", + }), + { valid: false }, + ); +}); + test("a correctly signed exchange signing key is accepted", async () => { const res = await nativeCryptoR.isValidSignKey(nativeCryptoR, { masterPub, 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>; + isValidPurseMergeSignature( + req: PurseMergeSignatureValidationRequest, + ): Promise<ValidationResult>; + isValidWireFee(req: WireFeeValidationRequest): Promise<ValidationResult>; isValidGlobalFees( @@ -478,6 +482,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<ValidationResult> { throw new Error("Function not implemented."); }, + isValidPurseMergeSignature: function ( + req: PurseMergeSignatureValidationRequest, + ): Promise<ValidationResult> { + throw new Error("Function not implemented."); + }, isValidWireFee: function ( req: WireFeeValidationRequest, ): Promise<ValidationResult> { @@ -913,6 +922,14 @@ export interface PurseStatusValidationRequest { exchangeSig: EddsaSignatureString; } +export interface PurseMergeSignatureValidationRequest { + mergeTimestamp: TalerProtocolTimestamp; + pursePub: EddsaPublicKeyString; + reservePayto: string; + mergePub: EddsaPublicKeyString; + mergeSig: EddsaSignatureString; +} + export interface ContractTermsValidationRequest { contractTermsHash: string; sig: string; @@ -1503,6 +1520,24 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { }; }, + async isValidPurseMergeSignature( + tci: TalerCryptoInterfaceR, + req: PurseMergeSignatureValidationRequest, + ): Promise<ValidationResult> { + const p = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE) + .put(timestampRoundedToBuffer(req.mergeTimestamp)) + .put(decodeCrock(req.pursePub)) + .put(hashTruncate32(stringToBytes(req.reservePayto + "\0"))) + .build(); + return { + valid: eddsaVerify( + p, + decodeCrock(req.mergeSig), + decodeCrock(req.mergePub), + ), + }; + }, + /** * Check if a wire fee is correctly signed. */ diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -924,8 +924,33 @@ async function processPendingMerge( kycLegiNeededResp, ); } - case HttpStatusCode.Conflict: - // FIXME: Check signature. + case HttpStatusCode.Conflict: { + const conflictReservePayto = reservePaytoFromExchange( + mergeResp.body.partner_url ?? peerInc.exchangeBaseUrl, + mergeResp.body.reserve_pub, + ); + const mergePub = ( + await wex.cryptoApi.eddsaGetPublic({ priv: peerInc.mergePriv }) + ).pub; + const { valid } = await wex.cryptoApi.isValidPurseMergeSignature({ + mergeTimestamp: mergeResp.body.merge_timestamp, + pursePub: peerInc.pursePub, + reservePayto: conflictReservePayto, + mergePub, + mergeSig: mergeResp.body.merge_sig, + }); + if (!valid) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "exchange returned an invalid purse merge conflict signature", + ); + } + if (conflictReservePayto === reservePayto) { + // The exchange committed our earlier identical request but its success + // response was lost. Continue with withdrawal idempotently. + break; + } await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { @@ -944,6 +969,7 @@ async function processPendingMerge( await h.update(rec, "merge-conflict"); }); return TaskRunResult.finished(); + } case HttpStatusCode.Gone: { const purseExpired = isPurseGoneByExpiration( contractTerms.purse_expiration,