commit f165236f8702313ea7558d637daccd54cc82b087
parent 3476ff77b9cd2cee5d374995b13b84fe7e721849
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:48 +0200
wallet-core: authenticate deposit confirmations
Diffstat:
5 files changed, 351 insertions(+), 0 deletions(-)
diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts
@@ -1588,6 +1588,10 @@ export type TrackTransaction =
| ({ type: "wired" } & TrackTransactionWired);
export interface BatchDepositSuccess {
+ // Total deposited so far for this contract, excluding deposit fees.
+ // Missing only on exchange protocol versions before v33.
+ accumulated_total_without_fee?: AmountString;
+
// Optional base URL of the exchange for looking up wire transfers
// associated with this transaction. If not given,
// the base URL is the same as the one used for this request.
@@ -1617,6 +1621,10 @@ export interface BatchDepositSuccess {
export const codecForBatchDepositSuccess = (): Codec<BatchDepositSuccess> =>
buildCodecForObject<BatchDepositSuccess>()
+ .property(
+ "accumulated_total_without_fee",
+ codecOptional(codecForAmountString()),
+ )
.property("exchange_pub", codecForEddsaPublicKey())
.property("exchange_sig", codecForEddsaSignature())
.property("exchange_timestamp", codecForTimestamp)
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts
@@ -21,6 +21,7 @@ import {
bufferForUint64,
bufferFromAmount,
createEddsaKeyPair,
+ createHashContext,
decodeCrock,
durationRoundedToBuffer,
DenomKeyType,
@@ -266,6 +267,104 @@ test("purse merge signature binds the purse, reserve and timestamp", async () =>
);
});
+test("deposit confirmation binds the request and every coin signature", async () => {
+ const contractTermsHash = encodeCrock(new Uint8Array(64).fill(10));
+ const wireHash = encodeCrock(new Uint8Array(64).fill(11));
+ const exchangeTimestamp = t(1_500);
+ const wireDeadline = t(2_000);
+ const refundDeadline = t(1_800);
+ const totalWithoutFee = "TESTKUDOS:3" as AmountString;
+ const coinSigs = [
+ encodeCrock(new Uint8Array(64).fill(12)),
+ encodeCrock(new Uint8Array(64).fill(13)),
+ ] as EddsaSignatureString[];
+ const merchantPub = encodeCrock(new Uint8Array(32).fill(14));
+ const coinSigHash = createHashContext();
+ coinSigs.forEach((sig) => coinSigHash.update(decodeCrock(sig)));
+ const sigBlob = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_DEPOSIT)
+ .put(decodeCrock(contractTermsHash))
+ .put(decodeCrock(wireHash))
+ .put(new Uint8Array(64))
+ .put(timestampRoundedToBuffer(exchangeTimestamp))
+ .put(timestampRoundedToBuffer(wireDeadline))
+ .put(timestampRoundedToBuffer(refundDeadline))
+ .put(bufferFromAmount(Amounts.parseOrThrow(totalWithoutFee)))
+ .put(coinSigHash.finish())
+ .put(decodeCrock(merchantPub))
+ .build();
+ const exchangePub = encodeCrock(signKey.eddsaPub) as EddsaPublicKeyString;
+ const exchangeSig = encodeCrock(
+ eddsaSign(sigBlob, signKey.eddsaPriv),
+ ) as EddsaSignatureString;
+ const request = {
+ contractTermsHash,
+ wireHash,
+ exchangeTimestamp,
+ wireDeadline,
+ refundDeadline,
+ totalWithoutFee,
+ coinSigs,
+ merchantPub,
+ exchangePub,
+ exchangeSig,
+ };
+
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidDepositConfirmation(nativeCryptoR, request),
+ { valid: true },
+ );
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidDepositConfirmation(nativeCryptoR, {
+ ...request,
+ coinSigs: coinSigs.slice(0, 1),
+ }),
+ { valid: false },
+ );
+});
+
+test("wire confirmation binds transfer, coin and contribution", async () => {
+ const wireHash = encodeCrock(new Uint8Array(64).fill(20));
+ const contractTermsHash = encodeCrock(new Uint8Array(64).fill(21));
+ const wireTransferId = encodeCrock(new Uint8Array(32).fill(22));
+ const coinPub = encodeCrock(new Uint8Array(32).fill(23));
+ const executionTime = t(2_500);
+ const coinContribution = "TESTKUDOS:2.5" as AmountString;
+ const sigBlob = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_WIRE)
+ .put(decodeCrock(wireHash))
+ .put(decodeCrock(contractTermsHash))
+ .put(decodeCrock(wireTransferId))
+ .put(decodeCrock(coinPub))
+ .put(timestampRoundedToBuffer(executionTime))
+ .put(bufferFromAmount(Amounts.parseOrThrow(coinContribution)))
+ .build();
+ const exchangePub = encodeCrock(signKey.eddsaPub) as EddsaPublicKeyString;
+ const exchangeSig = encodeCrock(
+ eddsaSign(sigBlob, signKey.eddsaPriv),
+ ) as EddsaSignatureString;
+ const request = {
+ wireHash,
+ contractTermsHash,
+ wireTransferId,
+ coinPub,
+ executionTime,
+ coinContribution,
+ exchangePub,
+ exchangeSig,
+ };
+
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidWireConfirmation(nativeCryptoR, request),
+ { valid: true },
+ );
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidWireConfirmation(nativeCryptoR, {
+ ...request,
+ coinContribution: "TESTKUDOS:2.4",
+ }),
+ { 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
@@ -274,6 +274,14 @@ export interface TalerCryptoInterface {
req: PurseMergeSignatureValidationRequest,
): Promise<ValidationResult>;
+ isValidDepositConfirmation(
+ req: DepositConfirmationValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidWireConfirmation(
+ req: WireConfirmationValidationRequest,
+ ): Promise<ValidationResult>;
+
isValidWireFee(req: WireFeeValidationRequest): Promise<ValidationResult>;
isValidGlobalFees(
@@ -487,6 +495,16 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<ValidationResult> {
throw new Error("Function not implemented.");
},
+ isValidDepositConfirmation: function (
+ req: DepositConfirmationValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidWireConfirmation: function (
+ req: WireConfirmationValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
isValidWireFee: function (
req: WireFeeValidationRequest,
): Promise<ValidationResult> {
@@ -930,6 +948,31 @@ export interface PurseMergeSignatureValidationRequest {
mergeSig: EddsaSignatureString;
}
+export interface DepositConfirmationValidationRequest {
+ contractTermsHash: HashCodeString;
+ wireHash: HashCodeString;
+ policyHash?: HashCodeString;
+ exchangeTimestamp: TalerProtocolTimestamp;
+ wireDeadline: TalerProtocolTimestamp;
+ refundDeadline?: TalerProtocolTimestamp;
+ totalWithoutFee: AmountString;
+ coinSigs: EddsaSignatureString[];
+ merchantPub: EddsaPublicKeyString;
+ exchangePub: EddsaPublicKeyString;
+ exchangeSig: EddsaSignatureString;
+}
+
+export interface WireConfirmationValidationRequest {
+ wireHash: HashCodeString;
+ contractTermsHash: HashCodeString;
+ wireTransferId: string;
+ coinPub: EddsaPublicKeyString;
+ executionTime: TalerProtocolTimestamp;
+ coinContribution: AmountString;
+ exchangePub: EddsaPublicKeyString;
+ exchangeSig: EddsaSignatureString;
+}
+
export interface ContractTermsValidationRequest {
contractTermsHash: string;
sig: string;
@@ -1538,6 +1581,63 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
};
},
+ async isValidDepositConfirmation(
+ tci: TalerCryptoInterfaceR,
+ req: DepositConfirmationValidationRequest,
+ ): Promise<ValidationResult> {
+ const coinSigHash = createHashContext();
+ for (const coinSig of req.coinSigs) {
+ coinSigHash.update(decodeCrock(coinSig));
+ }
+ const p = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_DEPOSIT)
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.wireHash))
+ .put(
+ req.policyHash
+ ? decodeCrock(req.policyHash)
+ : new Uint8Array(64),
+ )
+ .put(timestampRoundedToBuffer(req.exchangeTimestamp))
+ .put(timestampRoundedToBuffer(req.wireDeadline))
+ .put(
+ timestampRoundedToBuffer(
+ req.refundDeadline ?? TalerProtocolTimestamp.fromSeconds(0),
+ ),
+ )
+ .put(bufferFromAmount(Amounts.parseOrThrow(req.totalWithoutFee)))
+ .put(coinSigHash.finish())
+ .put(decodeCrock(req.merchantPub))
+ .build();
+ return {
+ valid: eddsaVerify(
+ p,
+ decodeCrock(req.exchangeSig),
+ decodeCrock(req.exchangePub),
+ ),
+ };
+ },
+
+ async isValidWireConfirmation(
+ tci: TalerCryptoInterfaceR,
+ req: WireConfirmationValidationRequest,
+ ): Promise<ValidationResult> {
+ const p = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_WIRE)
+ .put(decodeCrock(req.wireHash))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.wireTransferId))
+ .put(decodeCrock(req.coinPub))
+ .put(timestampRoundedToBuffer(req.executionTime))
+ .put(bufferFromAmount(Amounts.parseOrThrow(req.coinContribution)))
+ .build();
+ return {
+ valid: eddsaVerify(
+ p,
+ decodeCrock(req.exchangeSig),
+ decodeCrock(req.exchangePub),
+ ),
+ };
+ },
+
/**
* Check if a wire fee is correctly signed.
*/
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -123,6 +123,10 @@ import {
getScopeForAllExchanges,
markExchangeUsed,
} from "./exchanges.js";
+import {
+ requireValidExchangeDepositConfirmation,
+ requireValidExchangeWireConfirmation,
+} from "./exchange-signatures.js";
import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js";
import { SignContractTermsHashResponse } from "./crypto/cryptoTypes.js";
import { WithdrawalGroupStatus } from "./db-common.js";
@@ -1814,6 +1818,36 @@ interface SubmitBatchArgs {
coinIndexes: number[];
}
+async function getBatchDepositTotalWithoutFee(
+ wex: WalletExecutionContext,
+ coins: BatchDepositRequestCoin[],
+): Promise<AmountJson> {
+ return await wex.runWalletDbTx(async (tx) => {
+ const coinRecords = await tx.getCoinsByPubs(
+ coins.map((coin) => coin.coin_pub),
+ );
+ 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`);
+ const denom = denoms.get(denomRefKey(coin));
+ checkDbInvariant(
+ !!denom,
+ `denomination for deposit coin ${depositCoin.coin_pub} not found`,
+ );
+ netContributions.push(
+ Amounts.sub(depositCoin.contribution, denom.feeDeposit).amount,
+ );
+ }
+ const currency = Amounts.currencyOf(coins[0].contribution);
+ return Amounts.sumOrZero(currency, netContributions).amount;
+ });
+}
+
/**
* Submit a single deposit batch to the exchange.
*
@@ -1886,6 +1920,30 @@ async function submitDepositBatch(
}
}
+ const batchTotalWithoutFee = await getBatchDepositTotalWithoutFee(wex, coins);
+ const signedTotalWithoutFee = Amounts.parseOrThrow(
+ depositResp.body.accumulated_total_without_fee ??
+ Amounts.stringify(batchTotalWithoutFee),
+ );
+ if (Amounts.cmp(signedTotalWithoutFee, batchTotalWithoutFee) < 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ {},
+ "exchange deposit confirmation covers less than the submitted batch",
+ );
+ }
+ await requireValidExchangeDepositConfirmation(wex, {
+ exchangeBaseUrl,
+ contractTermsHash: depositGroup.contractTermsHash,
+ wireHash: hashWire(depositGroup.wire.payto_uri, depositGroup.wire.salt),
+ wireDeadline: contractTerms.wire_transfer_deadline,
+ refundDeadline: contractTerms.refund_deadline,
+ totalWithoutFee: Amounts.stringify(signedTotalWithoutFee),
+ coinSigs: coins.map((coin) => coin.coin_sig),
+ merchantPub: contractTerms.merchant_pub,
+ response: depositResp.body,
+ });
+
await wex.runWalletDbTx(async (tx) => {
const dg = await tx.getDepositGroup(depositGroupId);
if (!dg) {
@@ -2094,6 +2152,13 @@ async function trackDeposit(
}
case "ok": {
const wired = trackResp.body;
+ await requireValidExchangeWireConfirmation(wex, {
+ exchangeBaseUrl: exchangeUrl,
+ wireHash,
+ contractTermsHash: depositGroup.contractTermsHash,
+ coinPub,
+ response: wired,
+ });
return { type: "wired", ...wired };
}
default:
diff --git a/packages/taler-wallet-core/src/exchange-signatures.ts b/packages/taler-wallet-core/src/exchange-signatures.ts
@@ -17,12 +17,15 @@
import {
AbsoluteTime,
AmountString,
+ BatchDepositSuccess,
Duration,
EddsaPublicKeyString,
ExchangePurseStatus,
+ HashCodeString,
TalerError,
TalerErrorCode,
TalerProtocolTimestamp,
+ TrackTransactionWired,
} from "@gnu-taler/taler-util";
import {
timestampProtocolFromDb,
@@ -148,3 +151,79 @@ export async function requireValidExchangeRefundConfirmation(
);
}
}
+
+export async function requireValidExchangeDepositConfirmation(
+ wex: WalletExecutionContext,
+ args: {
+ exchangeBaseUrl: string;
+ contractTermsHash: HashCodeString;
+ wireHash: HashCodeString;
+ wireDeadline: TalerProtocolTimestamp;
+ refundDeadline?: TalerProtocolTimestamp;
+ totalWithoutFee: AmountString;
+ coinSigs: string[];
+ merchantPub: string;
+ response: BatchDepositSuccess;
+ },
+): Promise<void> {
+ const [knownKey, signatureResult] = await Promise.all([
+ isKnownExchangeSigningKey(
+ wex,
+ args.exchangeBaseUrl,
+ args.response.exchange_pub,
+ AbsoluteTime.fromProtocolTimestamp(args.response.exchange_timestamp),
+ ),
+ wex.cryptoApi.isValidDepositConfirmation({
+ contractTermsHash: args.contractTermsHash,
+ wireHash: args.wireHash,
+ exchangeTimestamp: args.response.exchange_timestamp,
+ wireDeadline: args.wireDeadline,
+ refundDeadline: args.refundDeadline,
+ totalWithoutFee: args.totalWithoutFee,
+ coinSigs: args.coinSigs,
+ merchantPub: args.merchantPub,
+ exchangePub: args.response.exchange_pub,
+ exchangeSig: args.response.exchange_sig,
+ }),
+ ]);
+ if (!knownKey || !signatureResult.valid) {
+ throw invalidExchangeSignature(
+ "exchange returned an invalid deposit confirmation signature",
+ );
+ }
+}
+
+export async function requireValidExchangeWireConfirmation(
+ wex: WalletExecutionContext,
+ args: {
+ exchangeBaseUrl: string;
+ wireHash: HashCodeString;
+ contractTermsHash: HashCodeString;
+ coinPub: string;
+ response: TrackTransactionWired;
+ },
+): Promise<void> {
+ const [knownKey, signatureResult] = await Promise.all([
+ isKnownExchangeSigningKey(
+ wex,
+ args.exchangeBaseUrl,
+ args.response.exchange_pub,
+ AbsoluteTime.fromProtocolTimestamp(args.response.execution_time),
+ ),
+ wex.cryptoApi.isValidWireConfirmation({
+ wireHash: args.wireHash,
+ contractTermsHash: args.contractTermsHash,
+ wireTransferId: args.response.wtid,
+ coinPub: args.coinPub,
+ executionTime: args.response.execution_time,
+ coinContribution: args.response.coin_contribution,
+ exchangePub: args.response.exchange_pub,
+ exchangeSig: args.response.exchange_sig,
+ }),
+ ]);
+ if (!knownKey || !signatureResult.valid) {
+ throw invalidExchangeSignature(
+ "exchange returned an invalid wire confirmation signature",
+ );
+ }
+}