taler-typescript-core

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

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:
Mpackages/taler-util/src/amounts.ts | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
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) {