taler-typescript-core

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

commit 2f9787972171ef87c7fb7ba86825a3f8339a376b
parent a3ab13cd77db45fffb69714e3badbfb6674e0920
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 19 Mar 2025 15:50:04 +0100

WIP: token domain matching logic

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-tokens.ts | 12++++--------
Mpackages/taler-util/src/types-taler-wallet.ts | 33++++++++-------------------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 5++---
Apackages/taler-wallet-core/src/tokenSelection.test.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/tokenSelection.ts | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
5 files changed, 230 insertions(+), 59 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts @@ -34,7 +34,6 @@ import { succeedOrThrow, TalerMerchantInstanceHttpClient, TalerProtocolTimestamp, - TokenAvailabilityHint, TokenFamilyKind, } from "@gnu-taler/taler-util"; @@ -197,10 +196,8 @@ export async function runWalletTokensTest(t: GlobalTestState) { ); const tokenDetails = choicesRes.choices[1].tokenDetails; - t.assertTrue(tokenDetails !== undefined); - t.assertTrue(tokenDetails.causeHint === undefined); - t.assertTrue(tokenDetails.tokensAvailable === 1); - t.assertTrue(tokenDetails.tokensRequested === 1); + t.assertTrue(tokenDetails?.tokensAvailable === 1); + t.assertTrue(tokenDetails?.tokensRequested === 1); } await walletClient.call(WalletApiOperation.ConfirmPay, { @@ -249,9 +246,8 @@ export async function runWalletTokensTest(t: GlobalTestState) { ); const tokenDetails = choicesRes.choices[1].tokenDetails; - t.assertTrue(tokenDetails?.causeHint === TokenAvailabilityHint.TokensInsufficient); - t.assertTrue(tokenDetails.tokensAvailable === 0); - t.assertTrue(tokenDetails.tokensRequested === 1); + t.assertTrue(tokenDetails?.tokensAvailable === 0); + t.assertTrue(tokenDetails?.tokensRequested === 1); } // should fail because we have no tokens left diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -878,33 +878,8 @@ export interface PaymentInsufficientBalanceDetails { }; } -export enum TokenAvailabilityHint { - /** - * Not enough tokens to pay the order. - * Should result in an error. - */ - TokensInsufficient = "tokens-insufficient", - - /** - * Merchant is untrusted by one or more tokens. - * Should result in an error. - */ - TokensUntrusted = "tokens-untrusted", - - /** - * Merchant is unexpected by one or more tokens. - * Should result in a warning. - */ - TokensUnexpected = "tokens-unexpected", -} - export interface PaymentTokenAvailabilityDetails { /** - * Hint for errors or warnings in the token selection, if any. - */ - causeHint?: TokenAvailabilityHint; - - /** * Number of tokens requested by the merchant. */ tokensRequested: number; @@ -929,12 +904,20 @@ export interface PaymentTokenAvailabilityDetails { */ tokensUntrusted: number; + /** + * Number of tokens with a malformed domain. + * + * Cannot be used to pay, so an error should be displayed. + */ + tokensInvalid: number; + perTokenFamily: { [tokenIssuePubHash: string]: { requested: number; available: number; unexpected: number; untrusted: number; + invalid: number; }; }; } diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -2408,7 +2408,6 @@ export async function getChoicesForPayment( throw Error("expected payment transaction ID"); } const proposalId = parsedTx.proposalId; - const ctx = new PayMerchantTransactionContext(wex, proposalId); const proposal = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { @@ -2441,6 +2440,7 @@ export async function getChoicesForPayment( tokensAvailable: 0, tokensUnexpected: 0, tokensUntrusted: 0, + tokensInvalid: 0, perTokenFamily: {}, }, }); @@ -2495,7 +2495,6 @@ export async function getChoicesForPayment( forcedSelection: forcedCoinSel, }); - let coins: SelectedProspectiveCoin[] | undefined = undefined; logger.trace("coin selection result", selectCoinsResult); switch (selectCoinsResult.type) { @@ -2525,7 +2524,7 @@ export async function getChoicesForPayment( return { choices, - // TODO: calculate + // TODO: calculate! defaultChoiceIndex: 0, automaticExecution: false, }; diff --git a/packages/taler-wallet-core/src/tokenSelection.test.ts b/packages/taler-wallet-core/src/tokenSelection.test.ts @@ -0,0 +1,102 @@ +/* + This file is part of GNU Taler + (C) 2025 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 { + MerchantContractTokenKind, +} from "@gnu-taler/taler-util"; +import test from "ava"; +import { + TokenMerchantVerificationResult, + verifyTokenMerchant, +} from "./tokenSelection.js"; + +test("match trusted_domains and expected_domains against merchant", (t) => { + const merchant0 = "https://merchant.net/"; + const merchant1 = "https://backend.test.taler.net/"; + const merchant2 = "https://backend.demo.taler.net/"; + const merchant3 = "https://backend.demo.evil.net/"; + + // (merchantBaseUrl, tokenMerchantBaseUrl, tokenDetails) + + t.assert( + verifyTokenMerchant(merchant0, merchant0, { + class: MerchantContractTokenKind.Discount, + expected_domains: [], + }) === TokenMerchantVerificationResult.Automatic, + ); + + t.assert( + verifyTokenMerchant(merchant1, merchant0, { + class: MerchantContractTokenKind.Discount, + expected_domains: [], + }) === TokenMerchantVerificationResult.Unexpected, + ); + + t.assert( + verifyTokenMerchant(merchant2, merchant1, { + class: MerchantContractTokenKind.Discount, + expected_domains: [".taler*.net"], + }) === TokenMerchantVerificationResult.Invalid, + ); + + t.assert( + verifyTokenMerchant(merchant2, merchant1, { + class: MerchantContractTokenKind.Discount, + expected_domains: ["*.taler.net"], + }) === TokenMerchantVerificationResult.Automatic, + ); + + t.assert( + verifyTokenMerchant(merchant3, merchant1, { + class: MerchantContractTokenKind.Discount, + expected_domains: ["*.taler.net"], + }) === TokenMerchantVerificationResult.Unexpected, + ); + + t.assert( + verifyTokenMerchant(merchant3, merchant1, { + class: MerchantContractTokenKind.Discount, + expected_domains: ["*"], + }) === TokenMerchantVerificationResult.Automatic, + ); + + t.assert( + verifyTokenMerchant(merchant2, merchant1, { + class: MerchantContractTokenKind.Subscription, + trusted_domains: ["*.taler.net"], + }) === TokenMerchantVerificationResult.Automatic, + ); + + t.assert( + verifyTokenMerchant(merchant3, merchant1, { + class: MerchantContractTokenKind.Subscription, + trusted_domains: ["*.taler.net"], + }) === TokenMerchantVerificationResult.Untrusted, + ); + + t.assert( + verifyTokenMerchant(merchant3, merchant1, { + class: MerchantContractTokenKind.Subscription, + trusted_domains: ["*.taler.net"], + }) === TokenMerchantVerificationResult.Untrusted, + ); + + t.assert( + verifyTokenMerchant(merchant3, merchant1, { + class: MerchantContractTokenKind.Subscription, + trusted_domains: ["*"], + }) === TokenMerchantVerificationResult.Automatic, + ); +}); diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts @@ -1,3 +1,18 @@ +/* + This file is part of GNU Taler + (C) 2025 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 { AbsoluteTime, assertUnreachable, @@ -5,11 +20,15 @@ import { Logger, MerchantContractInputType, MerchantContractTermsV1, + MerchantContractTokenDetails, MerchantContractTokenKind, PaymentTokenAvailabilityDetails, - TokenAvailabilityHint, } from "@gnu-taler/taler-util"; -import { timestampProtocolFromDb, TokenRecord, WalletDbReadOnlyTransaction } from "./db.js"; +import { + timestampProtocolFromDb, + TokenRecord, + WalletDbReadOnlyTransaction, +} from "./db.js"; import { WalletExecutionContext } from "./index.js"; const logger = new Logger("tokenSelection.ts"); @@ -57,24 +76,91 @@ export enum TokenMerchantVerificationResult { * User should be warned before using. */ Unexpected = "unexpected-domain", + + /** + * Domain is not correctly formatted. + */ + Invalid = "invalid-domain", } /** * Verify that merchant URL matches `trusted_domains' or * `expected_domains' in the token family. + * + * Format: FQD with optional *. (multi-level) wildcard at the beginning. + * Format: single * alone (catch-all). */ export function verifyTokenMerchant( merchantBaseUrl: string, - token: TokenRecord, + tokenMerchantBaseUrl: string, + tokenDetails: MerchantContractTokenDetails, ): TokenMerchantVerificationResult { - // TODO: implement properly - const data = token.extraData; - switch (data.class) { + const parsedUrl = new URL(merchantBaseUrl); + const merchantDomain = parsedUrl.hostname; + + const parsedTokenUrl = new URL(tokenMerchantBaseUrl); + const tokenDomain = parsedTokenUrl.hostname; + + const domains: string[] = []; + switch (tokenDetails.class) { case MerchantContractTokenKind.Discount: - return TokenMerchantVerificationResult.Automatic; + domains.push(...tokenDetails.expected_domains); + break; case MerchantContractTokenKind.Subscription: - return TokenMerchantVerificationResult.Automatic; + domains.push(...tokenDetails.trusted_domains); + break; } + + if (domains.find(t => t === "*")) { + // If catch-all (*) is present, token can be spent anywhere + return TokenMerchantVerificationResult.Automatic; + } else if (merchantDomain === tokenDomain) { + // Tokens are always spendable on their merchant of origin + return TokenMerchantVerificationResult.Automatic; + } else if (domains.length === 0) { + // If not the merchant of origin, but no domains were specified + // in the token details, it cannot/should not be spent. + switch (tokenDetails.class) { + case MerchantContractTokenKind.Discount: + return TokenMerchantVerificationResult.Unexpected; + case MerchantContractTokenKind.Subscription: + return TokenMerchantVerificationResult.Untrusted; + default: + assertUnreachable(tokenDetails); + } + } + + var unexpected = false; + var untrusted = false; + const regex = new RegExp("^(\\*\\.)?([\\w\\d]+\\.)+[\\w\\d]+$"); + for (const domain of domains) { + if (!regex.test(domain)) + return TokenMerchantVerificationResult.Invalid; + const matcher = new RegExp("^" + domain + .replace(".", "\\.") + .replace("*", "(([\\w\\d]+\.)+[\\w\\d]+)") + "$"); + if (matcher.test(merchantDomain)) { + continue; + } else { + switch (tokenDetails.class) { + case MerchantContractTokenKind.Discount: + unexpected = true; + continue; + case MerchantContractTokenKind.Subscription: + untrusted = true; + continue; + default: + assertUnreachable(tokenDetails); + } + } + } + + if (untrusted) + return TokenMerchantVerificationResult.Untrusted; + if (unexpected) + return TokenMerchantVerificationResult.Unexpected; + + return TokenMerchantVerificationResult.Automatic; } export async function selectPayTokensInTx( @@ -85,6 +171,7 @@ export async function selectPayTokensInTx( ] >, req: SelectPayTokensRequest, + ): Promise<SelectPayTokensResult> { if (logger.shouldLogTrace()) { logger.trace(`selecting tokens for ${j2s(req)}`); @@ -101,10 +188,12 @@ export async function selectPayTokensInTx( tokensAvailable: 0, tokensUnexpected: 0, tokensUntrusted: 0, + tokensInvalid: 0, perTokenFamily: {}, }; var insufficient = false; + const inputs = req.contractTerms.choices[req.choiceIndex].inputs; for (const input of inputs) { if (input.type == MerchantContractInputType.Token) { @@ -117,6 +206,7 @@ export async function selectPayTokensInTx( available: 0, unexpected: 0, untrusted: 0, + invalid: 0, }; } else { details.perTokenFamily[slug].requested += count; @@ -135,29 +225,29 @@ export async function selectPayTokensInTx( AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validAfter)), AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)), )).filter(tok => { - const res = verifyTokenMerchant(proposal.merchantBaseUrl, tok); + const res = verifyTokenMerchant( + proposal.merchantBaseUrl, + tok.merchantBaseUrl, + tok.extraData, + ); switch (res) { case TokenMerchantVerificationResult.Automatic: - details.tokensAvailable += 1; - details.perTokenFamily[slug].available += 1; - break; + return true; // usable case TokenMerchantVerificationResult.Unexpected: details.tokensUnexpected += 1; - details.tokensAvailable += 1; details.perTokenFamily[slug].unexpected += 1; - details.perTokenFamily[slug].available += 1; - details.causeHint = TokenAvailabilityHint.TokensUnexpected; - break; + return true; // usable case TokenMerchantVerificationResult.Untrusted: details.tokensUntrusted += 1; details.perTokenFamily[slug].untrusted += 1; - details.causeHint = TokenAvailabilityHint.TokensUntrusted; - break; + return false; // non-usable + case TokenMerchantVerificationResult.Invalid: + details.tokensInvalid += 1; + details.perTokenFamily[slug].invalid += 1; + return false; // non-usable + default: + assertUnreachable(res); } - return ( - res === TokenMerchantVerificationResult.Automatic || - res === TokenMerchantVerificationResult.Unexpected - ); }).sort((a, b) => a.validBefore - b.validBefore); if (usable.length < count) { @@ -165,12 +255,13 @@ export async function selectPayTokensInTx( continue; } + details.tokensAvailable += count; + details.perTokenFamily[slug].available += count; records.push(...usable.slice(0, count)); } } if (insufficient) { - details.causeHint = TokenAvailabilityHint.TokensInsufficient; return { type: "failure", details: details,