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:
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.
*/