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:
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,