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