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:
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()))