taler-typescript-core

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

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:
Mpackages/taler-util/src/types-taler-exchange.ts | 8++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchange-signatures.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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", + ); + } +}