taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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`, + ); + } +});