commit 0005c5b5ba41d13dbd3e331366956f9939816464
parent 516e42ae5403d39df98c9a693943e03b436567a4
Author: Florian Dold <dold@taler.net>
Date: Mon, 20 Jul 2026 20:43:16 +0200
util: validate amount currencies and arithmetic operands
Currency validation ran after toUpperCase, which is neither ASCII-only nor
length-preserving, so non-ASCII input passed the ASCII check. Route every
entry point through a single normalizeCurrency that validates the raw string.
jsonifyAmount was a pure cast, so add, sub and cmp accepted NaN, Infinity and
negative fields. Also fix divide scaling in float64, sub not normalizing its
minuend, and divmod comparing currencies case-sensitively.
Diffstat:
1 file changed, 111 insertions(+), 29 deletions(-)
diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts
@@ -26,7 +26,6 @@ import {
Context,
DecodingError,
buildCodecForObject,
- codecForString,
renderContext,
} from "./codec.js";
import { Result, ResultError, ResultOk } from "./result.js";
@@ -161,9 +160,34 @@ function codecForAmountFractionNumber(): Codec<number> {
};
}
+/**
+ * Codec for the currency of an amount, applying the same rule as
+ * Amounts.isCurrency rather than accepting any string.
+ */
+export function codecForCurrency(): Codec<string> {
+ return {
+ decode(x: any, c?: Context): string {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ const norm = Amounts.normalizeCurrency(x);
+ if (norm === undefined) {
+ throw new DecodingError(
+ `expected valid currency at ${renderContext(
+ c,
+ )} but got ${JSON.stringify(x)}`,
+ );
+ }
+ return norm;
+ },
+ };
+}
+
export const codecForAmountJson = (): Codec<AmountJson> =>
buildCodecForObject<AmountJson>()
- .property("currency", codecForString())
+ .property("currency", codecForCurrency())
.property("value", codecForAmountValueNumber())
.property("fraction", codecForAmountFractionNumber())
.build("AmountJson");
@@ -263,13 +287,44 @@ export class Amounts {
* Get an amount that represents zero units of a currency.
*/
static zeroOfCurrency(currency: string): AmountJson {
+ const c = Amounts.normalizeCurrency(currency);
+ if (c === undefined) {
+ throw Error(`not a currency: ${JSON.stringify(currency)}`);
+ }
return {
- currency,
+ currency: c,
fraction: 0,
value: 0,
};
}
+ /**
+ * Check the numeric fields of an amount, and return it unchanged.
+ *
+ * A denormalized fraction is accepted: add, cmp and stringify normalize
+ * their operands. The currency format is not re-checked, since every way
+ * of constructing an amount already validates it via normalizeCurrency.
+ */
+ static checkAmountJson(amt: AmountJson): AmountJson {
+ if (typeof amt !== "object" || amt === null) {
+ throw Error("invalid amount (not an object)");
+ }
+ if (typeof amt.currency !== "string") {
+ throw Error("invalid amount (currency is not a string)");
+ }
+ for (const field of ["value", "fraction"] as const) {
+ const v = amt[field];
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) {
+ // JSON.stringify renders NaN and Infinity as "null".
+ const shown = typeof v === "number" ? String(v) : JSON.stringify(v);
+ throw Error(
+ `invalid amount (${field} must be a non-negative safe integer, got ${shown})`,
+ );
+ }
+ }
+ return amt;
+ }
+
static jsonifyAmount(amt: AmountLike): AmountJson {
if (typeof amt === "string") {
return Amounts.parseOrThrow(amt);
@@ -277,14 +332,14 @@ export class Amounts {
if (amt instanceof Amount) {
return amt.toJson();
}
- return amt;
+ return Amounts.checkAmountJson(amt);
}
static divmod(a1: AmountLike, a2: AmountLike): DivmodResult {
const am1 = Amounts.jsonifyAmount(a1);
const am2 = Amounts.jsonifyAmount(a2);
- if (am1.currency != am2.currency) {
- throw Error(`incompatible currency (${am1.currency} vs${am2.currency})`);
+ if (am1.currency.toUpperCase() != am2.currency.toUpperCase()) {
+ throw Error(`incompatible currency (${am1.currency} vs ${am2.currency})`);
}
const x1 =
@@ -388,8 +443,9 @@ export class Amounts {
static sub(a: AmountLike, ...rest: AmountLike[]): AmountResult {
const aJ = Amounts.jsonifyAmount(a);
const currency = aJ.currency;
- let value = aJ.value;
- let fraction = aJ.fraction;
+ // Normalize the minuend, as add() and cmp() do for their operands.
+ let value = aJ.value + Math.floor(aJ.fraction / amountFractionalBase);
+ let fraction = aJ.fraction % amountFractionalBase;
for (const b of rest) {
const bJ = Amounts.jsonifyAmount(b);
@@ -433,20 +489,13 @@ export class Amounts {
const af = a.fraction % amountFractionalBase;
const bv = b.value + Math.floor(b.fraction / amountFractionalBase);
const bf = b.fraction % amountFractionalBase;
- switch (true) {
- case av < bv:
- return -1;
- case av > bv:
- return 1;
- case af < bf:
- return -1;
- case af > bf:
- return 1;
- case af === bf:
- return 0;
- default:
- throw Error("assertion failed");
- }
+ // jsonifyAmount has established that all four are non-negative safe
+ // integers, so these comparisons are total.
+ if (av < bv) return -1;
+ if (av > bv) return 1;
+ if (af < bf) return -1;
+ if (af > bf) return 1;
+ return 0;
}
/**
@@ -471,9 +520,14 @@ export class Amounts {
return { value: a.value, fraction: a.fraction, currency: a.currency };
}
const r = a.value % n;
+ // Scale in BigInt: r can be as large as n-1, so r * amountFractionalBase
+ // exceeds 2^53 and float64 would round the fraction's low bits away.
+ const scaled =
+ (BigInt(r) * BigInt(amountFractionalBase) + BigInt(a.fraction)) /
+ BigInt(n);
return {
currency: a.currency,
- fraction: Math.floor((r * amountFractionalBase + a.fraction) / n),
+ fraction: Number(scaled),
value: Math.floor(a.value / n),
};
}
@@ -492,13 +546,28 @@ export class Amounts {
}
/**
- * Check whether a string is a valid currency for a Taler amount.
+ * Check whether a string is a valid currency for a Taler amount:
+ * at most 11 ASCII letters.
*/
static isCurrency(s: string): boolean {
return /^[a-zA-Z]{1,11}$/.test(s);
}
/**
+ * Validate a currency and return it in canonical (upper case) form,
+ * or undefined if it is not valid.
+ *
+ * Validation must run on the raw string: toUpperCase is neither ASCII-only
+ * nor length-preserving (U+017F folds to "S", U+FB01 to "FI").
+ */
+ static normalizeCurrency(s: string): string | undefined {
+ if (!Amounts.isCurrency(s)) {
+ return undefined;
+ }
+ return s.toUpperCase();
+ }
+
+ /**
* Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct.
*
* Currency name size limit is 11 of ASCII letters
@@ -516,11 +585,12 @@ export class Amounts {
if (c_idx === -1 || c_idx === 0) {
return Result.error(AmountParseError.MISSING_CURRENCY);
}
- if (c_idx > 11) {
+ const rawCurrency = s.substring(0, c_idx);
+ if (rawCurrency.length > 11) {
return Result.error(AmountParseError.CURRENCY_TOO_LONG);
}
- const currency = s.substring(0, c_idx).toUpperCase();
- if (!/^[a-zA-Z]+$/.test(currency)) {
+ const currency = Amounts.normalizeCurrency(rawCurrency);
+ if (currency === undefined) {
return Result.error(AmountParseError.BAD_CURRENCY);
}
const number = s.substring(c_idx + 1);
@@ -574,8 +644,12 @@ export class Amounts {
if (value > amountMaxValue) {
return undefined;
}
+ const currency = Amounts.normalizeCurrency(res[1]);
+ if (currency === undefined) {
+ return undefined;
+ }
return {
- currency: res[1].toUpperCase(),
+ currency,
fraction: Math.round(amountFractionalBase * Number.parseFloat(tail)),
value,
};
@@ -593,6 +667,14 @@ export class Amounts {
if (typeof s.currency !== "string") {
throw Error("invalid amount object");
}
+ const currency = Amounts.normalizeCurrency(s.currency);
+ if (currency === undefined) {
+ throw Error(
+ `invalid amount object (not a currency: ${JSON.stringify(
+ s.currency,
+ )})`,
+ );
+ }
if (typeof s.value !== "number") {
throw Error("invalid amount object");
}
@@ -609,7 +691,7 @@ export class Amounts {
) {
throw Error("invalid amount object (fraction out of range)");
}
- return { currency: s.currency, value: s.value, fraction: s.fraction };
+ return { currency, value: s.value, fraction: s.fraction };
} else if (typeof s === "string") {
const res = Amounts.parse(s);
if (!res) {