taler-typescript-core

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

commit 8a50f1f167f7f8177a0fa0e2630d744d33c94597
parent 68011403fc3dbf5292514d5a0c87b5f4e3da8a71
Author: Florian Dold <dold@taler.net>
Date:   Tue, 21 Jul 2026 01:09:38 +0200

util: validate the /keys response

22 required fields of /keys were decoded with codecForAny(), which does not
reject undefined, so a document holding only version, base_url and currency
decoded with the master public key and denominations left undefined.  Wire up
the existing codecs and add a union codec for the denomination groups.

Diffstat:
Apackages/taler-util/src/types-taler-exchange.test.ts | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-exchange.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 351 insertions(+), 24 deletions(-)

diff --git a/packages/taler-util/src/types-taler-exchange.test.ts b/packages/taler-util/src/types-taler-exchange.test.ts @@ -0,0 +1,239 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { codecForExchangeKeysResponse } from "./types-taler-exchange.js"; + +/** + * A /keys response captured from a live exchange (protocol 37:0:3), reduced + * to one denomination group per cipher and one denomination each, with the + * opaque base32 blobs truncated. + * + * Note that extensions/extensions_sig are absent, and the plain "CS" group + * carries no age_mask although the interface declares one. + */ +const REAL_KEYS: any = { + "version": "37:0:3", + "base_url": "http://localhost:8081/", + "currency": "TESTKUDOS", + "currency_specification": { + "name": "Test-kudos (Taler Demonstrator)", + "currency": "TESTKUDOS", + "common_amounts": [ + "TESTKUDOS:5", + "TESTKUDOS:10", + "TESTKUDOS:25", + "TESTKUDOS:50" + ], + "num_fractional_input_digits": 2, + "num_fractional_normal_digits": 2, + "num_fractional_trailing_zero_digits": 2, + "alt_unit_names": { + "0": "\u30c6", + "3": "k\u30c6", + "-3": "m\u30c6" + } + }, + "hard_limits": [], + "zero_limits": [], + "stefan_abs": "TESTKUDOS:1", + "stefan_log": "TESTKUDOS:1", + "stefan_lin": 0, + "asset_type": "fiat", + "kyc_enabled": false, + "kyc_swap_tos_acceptance": false, + "disable_direct_deposit": false, + "rewards_allowed": false, + "master_public_key": "DK9ESDMR9VGK66PJ", + "reserve_closing_delay": { + "d_us": 2419200000000 + }, + "default_p2p_push_expiration": { + "d_us": 2419200000000 + }, + "signkeys": [ + { + "stamp_start": { + "t_s": 1784568055 + }, + "stamp_expire": { + "t_s": 1791825655 + }, + "stamp_end": { + "t_s": 1854897655 + }, + "master_sig": "5RBMXK3WQK2BX6CH", + "key": "GV59TV4135ENJAES" + } + ], + "recoup": [], + "wads": [], + "accounts": [ + { + "payto_uri": "payto://x-taler-bank/localhost/exchange?receiver-name=exchange", + "priority": 0, + "debit_restrictions": [], + "credit_restrictions": [], + "master_sig": "MGFAA1ZVYHPNK54G" + } + ], + "wire_fees": { + "x-taler-bank": [ + { + "wire_fee": "TESTKUDOS:0.01", + "closing_fee": "TESTKUDOS:0.01", + "start_date": { + "t_s": 1767225600 + }, + "end_date": { + "t_s": 1798761600 + }, + "sig": "F4K7E168YX587XWQ" + } + ] + }, + "denominations": [ + { + "cipher": "RSA", + "denoms": [ + { + "master_sig": "EBDT9N9RWR4X0SF1", + "stamp_start": { + "t_s": 1784568055 + }, + "stamp_expire_withdraw": { + "t_s": 1784764800 + }, + "stamp_expire_deposit": { + "t_s": 1847836800 + }, + "stamp_expire_legal": { + "t_s": 1942444800 + }, + "rsa_pub": "020000XDV5WEHG8Q" + } + ], + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01", + "value": "TESTKUDOS:2" + }, + { + "cipher": "CS", + "denoms": [ + { + "master_sig": "WGMHHKDJ4WRAQM8Q", + "stamp_start": { + "t_s": 1815412833 + }, + "stamp_expire_withdraw": { + "t_s": 1816214400 + }, + "stamp_expire_deposit": { + "t_s": 1879286400 + }, + "stamp_expire_legal": { + "t_s": 1973894400 + }, + "cs_pub": "97ZMTV6M1SC4N00W" + } + ], + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01", + "value": "TESTKUDOS:0.1" + } + ], + "auditors": [], + "global_fees": [ + { + "start_date": { + "t_s": 1767225600 + }, + "end_date": { + "t_s": 1798761600 + }, + "history_fee": "TESTKUDOS:0.01", + "account_fee": "TESTKUDOS:0.01", + "purse_fee": "TESTKUDOS:0", + "history_expiration": { + "d_us": 31536000000000 + }, + "purse_timeout": { + "d_us": 3600000000 + }, + "purse_account_limit": 5, + "master_sig": "V9X36VW7GQD6DF5C" + } + ], + "list_issue_date": { + "t_s": 1784568055 + }, + "wallet_balance_limit_without_kyc": [], + "tiny_amount": "TESTKUDOS:0.01", + "exchange_pub": "GV59TV4135ENJAES", + "exchange_sig": "QXYANBP82S1HGKR3" + }; + +test("codecForExchangeKeysResponse accepts a real /keys document", (t) => { + const keys = codecForExchangeKeysResponse().decode(REAL_KEYS); + assert.strictEqual(keys.currency, "TESTKUDOS"); + assert.strictEqual(keys.denominations.length, 2); + assert.deepStrictEqual( + keys.denominations.map((g) => g.cipher).sort(), + ["CS", "RSA"], + ); + assert.ok(keys.master_public_key.length > 0); + assert.ok(keys.signkeys.length > 0); + assert.ok(keys.accounts.length > 0); + // Absent in the real document; must stay optional. + assert.strictEqual(keys.extensions, undefined); + assert.strictEqual(keys.extensions_sig, undefined); +}); + +test("codecForExchangeKeysResponse rejects a /keys document that is missing required fields", (t) => { + // This is the exact shape that used to decode successfully, leaving + // denominations, master_public_key and signkeys undefined behind a type + // that declares them required. + assert.throws(() => + codecForExchangeKeysResponse().decode({ + version: "37:0:3", + base_url: "https://exchange.example/", + currency: "TESTKUDOS", + }), + ); +}); + +test("codecForExchangeKeysResponse rejects tampered fields", (t) => { + const mutate = (fn: (k: any) => void): (() => unknown) => { + const k = JSON.parse(JSON.stringify(REAL_KEYS)); + fn(k); + return () => codecForExchangeKeysResponse().decode(k); + }; + assert.throws(mutate((k) => delete k.master_public_key)); + assert.throws(mutate((k) => (k.master_public_key = { nested: true }))); + assert.throws(mutate((k) => (k.denominations = "not an array"))); + assert.throws(mutate((k) => (k.denominations[0].cipher = "ROT13"))); + assert.throws(mutate((k) => delete k.signkeys[0].master_sig)); + assert.throws(mutate((k) => (k.list_issue_date = "now"))); + assert.throws( + mutate((k) => (k.wire_fees["x-taler-bank"][0].wire_fee = "free")), + ); + assert.throws(mutate((k) => (k.stefan_lin = "not a number"))); +}); diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -994,8 +994,85 @@ export const codecForGlobalFees = (): Codec<GlobalFees> => .property("master_sig", codecForEddsaSignature()) .build("GlobalFees"); -// FIXME: Validate properly! -export const codecForNgDenominations: Codec<DenomGroup> = codecForAny(); +/** + * Fields shared by every denomination entry. + */ +function propsForDenomCommon<T extends DenomCommon>( + b: ReturnType<typeof buildCodecForObject<T>>, +) { + return b + .property("master_sig", codecForEddsaSignature()) + .property("stamp_start", codecForTimestamp) + .property("stamp_expire_withdraw", codecForTimestamp) + .property("stamp_expire_deposit", codecForTimestamp) + .property("stamp_expire_legal", codecForTimestamp) + .property("lost", codecOptional(codecForBoolean())); +} + +type RsaDenom = { rsa_pub: RsaPublicKeyString } & DenomCommon; +type CsDenom = { cs_pub: Cs25519Point } & DenomCommon; + +const codecForRsaDenom = (): Codec<RsaDenom> => + propsForDenomCommon(buildCodecForObject<RsaDenom>()) + .property("rsa_pub", codecForString()) + .build("TalerExchangeApi.RsaDenom"); + +const codecForCsDenom = (): Codec<CsDenom> => + propsForDenomCommon(buildCodecForObject<CsDenom>()) + .property("cs_pub", codecForString()) + .build("TalerExchangeApi.CsDenom"); + +/** + * Fields shared by every denomination group. + */ +function propsForDenomGroupCommon<T extends DenomGroupCommon>( + b: ReturnType<typeof buildCodecForObject<T>>, +) { + return b + .property("value", codecForAmountString()) + .property("fee_withdraw", codecForAmountString()) + .property("fee_deposit", codecForAmountString()) + .property("fee_refresh", codecForAmountString()) + .property("fee_refund", codecForAmountString()); +} + +const codecForDenomGroupRsa = (): Codec<DenomGroupRsa> => + propsForDenomGroupCommon(buildCodecForObject<DenomGroupRsa>()) + .property("cipher", codecForConstString("RSA")) + .property("denoms", codecForList(codecForRsaDenom())) + .build("TalerExchangeApi.DenomGroupRsa"); + +const codecForDenomGroupCs = (): Codec<DenomGroupCs> => + propsForDenomGroupCommon(buildCodecForObject<DenomGroupCs>()) + .property("cipher", codecForConstString("CS")) + // Not age restricted: the exchange sends no age mask here, although the + // interface declares one. + .property("age_mask", codecOptional(codecForNumber()) as Codec<AgeMask>) + .property("denoms", codecForList(codecForCsDenom())) + .build("TalerExchangeApi.DenomGroupCs"); + +const codecForDenomGroupRsaAgeRestricted = (): Codec<DenomGroupRsaAgeRestricted> => + propsForDenomGroupCommon(buildCodecForObject<DenomGroupRsaAgeRestricted>()) + .property("cipher", codecForConstString("RSA+age_restricted")) + .property("age_mask", codecForNumber()) + .property("denoms", codecForList(codecForRsaDenom())) + .build("TalerExchangeApi.DenomGroupRsaAgeRestricted"); + +const codecForDenomGroupCsAgeRestricted = (): Codec<DenomGroupCsAgeRestricted> => + propsForDenomGroupCommon(buildCodecForObject<DenomGroupCsAgeRestricted>()) + .property("cipher", codecForConstString("CS+age_restricted")) + .property("age_mask", codecForNumber()) + .property("denoms", codecForList(codecForCsDenom())) + .build("TalerExchangeApi.DenomGroupCsAgeRestricted"); + +export const codecForNgDenominations = (): Codec<DenomGroup> => + buildCodecForUnion<DenomGroup>() + .discriminateOn("cipher") + .alternative("RSA", codecForDenomGroupRsa()) + .alternative("CS", codecForDenomGroupCs()) + .alternative("RSA+age_restricted", codecForDenomGroupRsaAgeRestricted()) + .alternative("CS+age_restricted", codecForDenomGroupCsAgeRestricted()) + .build("TalerExchangeApi.DenomGroup"); export const codecForWireFeesJson = (): Codec<WireFeesJson> => buildCodecForObject<WireFeesJson>() @@ -2504,29 +2581,40 @@ export const codecForExchangeKeysResponse = (): Codec<ExchangeKeysResponse> => .property("version", codecForString()) .property("base_url", codecForURLString()) .property("currency", codecForString()) - .property("accounts", codecForAny()) - .property("asset_type", codecForAny()) - .property("auditors", codecForAny()) - .property("currency_specification", codecForAny()) - .property("zero_limits", codecForAny()) - .property("hard_limits", codecForAny()) - .property("denominations", codecForAny()) - .property("exchange_pub", codecForAny()) - .property("exchange_sig", codecForAny()) - .property("extensions", codecForAny()) - .property("extensions_sig", codecForAny()) - .property("global_fees", codecForAny()) - .property("list_issue_date", codecForAny()) - .property("master_public_key", codecForAny()) - .property("recoup", codecForAny()) - .property("reserve_closing_delay", codecForAny()) - .property("signkeys", codecForAny()) - .property("stefan_abs", codecForAny()) - .property("stefan_lin", codecForAny()) - .property("stefan_log", codecForAny()) + .property("accounts", codecForList(codecForExchangeWireAccount())) + .property("asset_type", codecForString()) + .property("auditors", codecForList(codecForAuditor())) + .property( + "currency_specification", + codecOptional(codecForCurrencySpecificiation()), + ) + .property( + "zero_limits", + codecOptional(codecForList(codecForZeroLimitedOperation())), + ) + .property("hard_limits", codecOptional(codecForList(codecForAccountLimit()))) + .property("denominations", codecForList(codecForNgDenominations())) + .property("exchange_pub", codecForEddsaPublicKey()) + .property("exchange_sig", codecForEddsaSignature()) + // Omitted entirely when the exchange has no extensions configured. + .property("extensions", codecOptional(codecForAny())) + .property("extensions_sig", codecOptional(codecForEddsaSignature())) + .property("global_fees", codecForList(codecForGlobalFees())) + .property("list_issue_date", codecForTimestamp) + .property("master_public_key", codecForEddsaPublicKey()) + .property("recoup", codecOptional(codecForList(codecForRecoup()))) + .property("reserve_closing_delay", codecForDuration) + .property("signkeys", codecForList(codecForExchangeSigningKey())) + .property("stefan_abs", codecForAmountString()) + .property("stefan_lin", codecForNumber()) + .property("stefan_log", codecForAmountString()) + // The wad structure is not modelled yet. .property("wads", codecForAny()) - .property("wallet_balance_limit_without_kyc", codecForAny()) - .property("wire_fees", codecForAny()) + .property( + "wallet_balance_limit_without_kyc", + codecOptional(codecForList(codecForAmountString())), + ) + .property("wire_fees", codecForMap(codecForList(codecForWireFeesJson()))) .property("kyc_enabled", codecOptional(codecForBoolean())) .property("shopping_url", codecOptional(codecForString())) .property("tiny_amount", codecOptional(codecForAmountString()))