commit 26f4b3963eff33182899a169b4219e24f32bc0ba
parent 1061309d9d50f19826449d9b00c7bb7b00edda57
Author: Florian Dold <dold@taler.net>
Date: Wed, 29 Jul 2026 12:09:02 +0200
wallet-core: check global fee validation with unbounded durations
An exchange may leave purse_timeout and history_expiration at forever, which
used to throw out of the signature check instead of verifying.
Issue: https://bugs.taler.net/n/11561
Diffstat:
1 file changed, 85 insertions(+), 0 deletions(-)
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts
@@ -14,14 +14,21 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
+ AmountString,
+ Amounts,
buildSigPS,
+ bufferForUint32,
+ bufferFromAmount,
createEddsaKeyPair,
decodeCrock,
+ durationRoundedToBuffer,
eddsaSign,
encodeCrock,
EddsaPublicKeyString,
EddsaSignatureString,
ExchangeSignKeyJson,
+ GlobalFees,
+ TalerProtocolDuration,
TalerProtocolTimestamp,
TalerSignaturePurpose,
timestampRoundedToBuffer,
@@ -178,3 +185,81 @@ test("a garbled signature is rejected", async () => {
});
assert.strictEqual(res.valid, false);
});
+
+function signedGlobalFees(
+ over: Omit<GlobalFees, "master_sig">,
+ claimed: Partial<GlobalFees> = {},
+): GlobalFees {
+ const p = buildSigPS(TalerSignaturePurpose.MASTER_GLOBAL_FEES)
+ .put(timestampRoundedToBuffer(over.start_date))
+ .put(timestampRoundedToBuffer(over.end_date))
+ .put(durationRoundedToBuffer(over.purse_timeout))
+ .put(durationRoundedToBuffer(over.history_expiration))
+ .put(bufferFromAmount(Amounts.parseOrThrow(over.history_fee)))
+ .put(bufferFromAmount(Amounts.parseOrThrow(over.account_fee)))
+ .put(bufferFromAmount(Amounts.parseOrThrow(over.purse_fee)))
+ .put(bufferForUint32(over.purse_account_limit))
+ .build();
+ return {
+ ...over,
+ master_sig: encodeCrock(
+ eddsaSign(p, master.eddsaPriv),
+ ) as EddsaSignatureString,
+ ...claimed,
+ };
+}
+
+const validFees: Omit<GlobalFees, "master_sig"> = {
+ start_date: t(1000),
+ end_date: t(2000),
+ history_fee: "TESTKUDOS:0.01" as AmountString,
+ account_fee: "TESTKUDOS:0.02" as AmountString,
+ purse_fee: "TESTKUDOS:0" as AmountString,
+ history_expiration: TalerProtocolDuration.fromSpec({ years: 1 }),
+ purse_account_limit: 5,
+ purse_timeout: TalerProtocolDuration.fromSpec({ hours: 1 }),
+};
+
+test("correctly signed global fees are accepted", async () => {
+ const res = await nativeCryptoR.isValidGlobalFees(nativeCryptoR, {
+ masterPub,
+ gf: signedGlobalFees(validFees),
+ });
+ assert.strictEqual(res.valid, true);
+});
+
+test("global fees with unbounded durations are accepted", async () => {
+ // An exchange may leave purse_timeout and history_expiration unbounded, and
+ // "forever" then has to survive all the way into the signature blob rather
+ // than blowing up the validation.
+ const gf = signedGlobalFees({
+ ...validFees,
+ end_date: TalerProtocolTimestamp.never(),
+ history_expiration: TalerProtocolDuration.forever(),
+ purse_timeout: TalerProtocolDuration.forever(),
+ });
+ const res = await nativeCryptoR.isValidGlobalFees(nativeCryptoR, {
+ masterPub,
+ gf,
+ });
+ assert.strictEqual(res.valid, true);
+});
+
+test("global fees claiming an unbounded duration they were not signed with are rejected", async () => {
+ // Pins that the durations really enter the blob: an entry signed over a
+ // finite timeout must not verify once it claims to be unbounded.
+ for (const claimed of [
+ { purse_timeout: TalerProtocolDuration.forever() },
+ { history_expiration: TalerProtocolDuration.forever() },
+ ]) {
+ const res = await nativeCryptoR.isValidGlobalFees(nativeCryptoR, {
+ masterPub,
+ gf: signedGlobalFees(validFees, claimed),
+ });
+ assert.strictEqual(
+ res.valid,
+ false,
+ `fees claiming ${Object.keys(claimed)[0]} = forever were accepted`,
+ );
+ }
+});