taler-typescript-core

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

commit 80cdacbd4cb85846d332cf5f29d3278f18d9fa38
parent 1ba58720d4ef0806107e6043663fc495b57f91e2
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:45 +0200

wallet-core: authenticate melt confirmations

Diffstat:
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 32++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 33+++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/refresh.test.ts | 16+++++++++++++++-
Mpackages/taler-wallet-core/src/refresh.ts | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 137 insertions(+), 4 deletions(-)

diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -121,6 +121,38 @@ test("slate creation reads outputs from the selected choice", () => { ]); }); +test("melt confirmation binds the refresh commitment and noreveal index", async () => { + const refreshCommitment = encodeCrock(new Uint8Array(64).fill(7)); + const norevealIndex = 1; + const sigBlob = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_MELT) + .put(decodeCrock(refreshCommitment)) + .put(bufferForUint32(norevealIndex)) + .build(); + const exchangePub = encodeCrock(signKey.eddsaPub) as EddsaPublicKeyString; + const exchangeSig = encodeCrock( + eddsaSign(sigBlob, signKey.eddsaPriv), + ) as EddsaSignatureString; + + assert.deepStrictEqual( + await nativeCryptoR.isValidMeltConfirmation(nativeCryptoR, { + refreshCommitment, + norevealIndex, + exchangePub, + exchangeSig, + }), + { valid: true }, + ); + assert.deepStrictEqual( + await nativeCryptoR.isValidMeltConfirmation(nativeCryptoR, { + refreshCommitment, + norevealIndex: 2, + exchangePub, + exchangeSig, + }), + { 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 @@ -258,6 +258,10 @@ export interface TalerCryptoInterface { req: PaymentSignatureValidationRequest, ): Promise<ValidationResult>; + isValidMeltConfirmation( + req: MeltConfirmationValidationRequest, + ): Promise<ValidationResult>; + isValidWireFee(req: WireFeeValidationRequest): Promise<ValidationResult>; isValidGlobalFees( @@ -451,6 +455,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<ValidationResult> { throw new Error("Function not implemented."); }, + isValidMeltConfirmation: function ( + req: MeltConfirmationValidationRequest, + ): Promise<ValidationResult> { + throw new Error("Function not implemented."); + }, isValidWireFee: function ( req: WireFeeValidationRequest, ): Promise<ValidationResult> { @@ -861,6 +870,13 @@ export interface PaymentSignatureValidationRequest { merchantPub: string; } +export interface MeltConfirmationValidationRequest { + refreshCommitment: HashCodeString; + norevealIndex: number; + exchangePub: EddsaPublicKeyString; + exchangeSig: EddsaSignatureString; +} + export interface ContractTermsValidationRequest { contractTermsHash: string; sig: string; @@ -1388,6 +1404,23 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { return { valid: eddsaVerify(p, sigBytes, pubBytes) }; }, + async isValidMeltConfirmation( + tci: TalerCryptoInterfaceR, + req: MeltConfirmationValidationRequest, + ): Promise<ValidationResult> { + const p = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_MELT) + .put(decodeCrock(req.refreshCommitment)) + .put(bufferForUint32(req.norevealIndex)) + .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/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts @@ -16,7 +16,21 @@ import { Amounts, DenominationInfo } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; -import { getTotalRefreshCostInternal } from "./refresh.js"; +import { + getTotalRefreshCostInternal, + requireValidNorevealIndex, +} from "./refresh.js"; + +test("melt noreveal index must be an integer inside kappa", () => { + assert.doesNotThrow(() => requireValidNorevealIndex(0, 3)); + assert.doesNotThrow(() => requireValidNorevealIndex(2, 3)); + for (const invalid of [-1, 3, 1.5, Number.NaN]) { + assert.throws( + () => requireValidNorevealIndex(invalid, 3), + /invalid noreveal index/, + ); + } +}); test("an impossible refresh costs the full remaining amount", () => { const amountLeft = Amounts.parseOrThrow("TESTKUDOS:4"); diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -23,6 +23,7 @@ * Imports. */ import { + AbsoluteTime, AgeRestriction, AmountJson, AmountLike, @@ -63,6 +64,7 @@ import { RefreshReason, succeedOrThrow, TalerErrorCode, + TalerError, TalerErrorDetail, TalerPreciseTimestamp, Transaction, @@ -95,6 +97,7 @@ import { RefreshOperationStatus, timestampPreciseFromDb, timestampPreciseToDb, + timestampProtocolFromDb, WalletDenomination, WalletCoinAvailability, WalletCoinHistory, @@ -1005,10 +1008,50 @@ async function refreshMelt( } const meltResponse = resp.body; - - // FIXME: Check exchange's signature. - const norevealIndex = meltResponse.noreveal_index; + requireValidNorevealIndex(norevealIndex, derived.planchets.length); + + const signingKeyKnown = await wex.runWalletDbTx(async (tx) => { + const details = await tx.getExchangeDetails(oldCoin.exchangeBaseUrl); + if (details?.rowId == null) { + return false; + } + const signKeys = await tx.getExchangeSignKeysByDetailsRowId(details.rowId); + const now = AbsoluteTime.now(); + const tolerance = Duration.fromSpec({ hours: 1 }); + const latestStart = AbsoluteTime.addDuration(now, tolerance); + const earliestExpiry = AbsoluteTime.subtractDuraction(now, tolerance); + return signKeys.some( + (x) => + x.signkeyPub === meltResponse.exchange_pub && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(x.stampStart), + ), + latestStart, + ) <= 0 && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(x.stampExpire), + ), + earliestExpiry, + ) > 0, + ); + }); + const { valid: signatureValid } = + await wex.cryptoApi.isValidMeltConfirmation({ + refreshCommitment: derived.hash, + norevealIndex, + exchangePub: meltResponse.exchange_pub, + exchangeSig: meltResponse.exchange_sig, + }); + if (!signingKeyKnown || !signatureValid) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "exchange returned an invalid melt confirmation signature", + ); + } refreshSession.norevealIndex = norevealIndex; @@ -1033,6 +1076,17 @@ async function refreshMelt( }); } +export function requireValidNorevealIndex(index: number, kappa: number): void { + if (Number.isInteger(index) && index >= 0 && index < kappa) { + return; + } + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + `exchange returned invalid noreveal index ${index} for kappa ${kappa}`, + ); +} + /** * Handle a "Gone" response from the exchange to a melt request. */