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