taler-typescript-core

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

commit 8c5a66a590db8d11daf69c60d22acd816c0d2b05
parent 7b835351774ff357c2bc08e06f81a8b275d26578
Author: Florian Dold <dold@taler.net>
Date:   Mon,  7 Sep 2026 12:24:32 +0200

wallet-core: restrict token spending to the issuing merchant

Compare full merchant base URLs with case-insensitive host and instance
names. Ignore token domain lists and reject foreign tokens during
selection and before generating or reusing payment signatures.

Keep legacy API fields for compatibility without allowing them to
override the issuer restriction.

Issue: https://bugs.taler.net/n/11564

Diffstat:
Mpackages/taler-util/src/types-taler-merchant.ts | 2++
Mpackages/taler-util/src/types-taler-wallet.ts | 14+++++---------
Mpackages/taler-wallet-core/src/db/records.ts | 8++++----
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 21+++++++++++++++++++++
Mpackages/taler-wallet-core/src/tokenSelection.test.ts | 334+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/taler-wallet-core/src/tokenSelection.ts | 140++++++++++++++-----------------------------------------------------------------
7 files changed, 363 insertions(+), 274 deletions(-)

diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -805,6 +805,7 @@ export interface MerchantContractSubscriptionTokenDetails { // these sites will re-issue tokens of this type // if the respective contract says so). May contain // "*" for any domain or subdomain. + // Wallet-core ignores this field and only spends tokens at their issuer. trusted_domains: string[]; } @@ -819,6 +820,7 @@ export interface MerchantContractDiscountTokenDetails { // is accepting a coupon from a competitor and thus // may be attaching different semantics (like get 20% // discount for my competitors 30% discount token). + // Wallet-core ignores this field and only spends tokens at their issuer. expected_domains: string[]; } diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1335,15 +1335,13 @@ export interface PaymentTokenAvailabilityDetails { tokensAvailable: number; /** - * Number of tokens for which the merchant is unexpected. - * - * Can be used to pay (i.e. with forced selection), - * but a warning should be displayed to the user. + * Legacy compatibility field. Always zero: tokens from another merchant + * are counted as untrusted and cannot be used. */ tokensUnexpected: number; /** - * Number of tokens for which the merchant is untrusted. + * Number of tokens not issued by the receiving merchant. * * Cannot be used to pay, so an error should be displayed. */ @@ -3011,10 +3009,8 @@ export interface ConfirmPayRequest { forcedCoinSel?: ForcedCoinSel; /** - * Whether token selection should be forced - * e.g. use tokens with non-matching `expected_domains' - * - * Only applies to v1 orders. + * Legacy compatibility option for v1 orders. Ignored: tokens can only + * be spent at their issuing merchant, even when this is true. */ forcedTokenSel?: boolean; diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -886,8 +886,8 @@ export interface WalletPurchasePayInfo { slateTokenSigs?: SignedTokenEnvelope[]; /** - * Whether token selection should be forced - * e.g. when merchant URL is not in `expected_domains' + * Legacy compatibility field. Ignored: tokens can only be spent at + * their issuing merchant. */ payTokenForcedSel?: boolean; @@ -2756,8 +2756,8 @@ export interface TokenFamilyInfo { descriptionI18n: any | undefined; /** - * Additional meta data, such as the trusted_domains - * or expected_domains. Depends on the kind. + * Token metadata, depending on the kind. Legacy trusted_domains and + * expected_domains fields do not authorize spending at another merchant. */ extraData: MerchantContractTokenDetails; diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -24,6 +24,7 @@ import { TransactionAction, TransactionIdStr, TalerPreciseTimestamp, + TalerError, TalerErrorCode, TimerAPI, TimerGroup, @@ -58,6 +59,7 @@ import { failProposalClaimPermanently, FAILED_CLAIM_RETENTION_MS, preparePayForUriV2, + generateTokenSigs, getCoinsToSpendForMerchantRepair, getAlreadyPaidRefundRequests, getPayMerchantAbortTransition, @@ -1174,3 +1176,119 @@ test("merchant abort accounts for contributions consumed by the refresh fee", () "pending", ); }); + +for (const cached of [false, true]) { + test(`token signature generation rejects foreign selections (cached=${cached})`, async () => { + const proposalId = "current"; + const local = { + tokenUsePub: "local", + merchantBaseUrl: "https://shop.example/instances/issuer/", + purchaseId: proposalId, + } as WalletToken; + const foreign = { + ...local, + tokenUsePub: "foreign", + merchantBaseUrl: "https://shop.example/instances/competitor/", + transactionId: "payment:current", + tokenUseSig: cached ? { token_pub: "foreign" } : undefined, + } as WalletToken; + const tokens = new Map([ + [local.tokenUsePub, local], + [foreign.tokenUsePub, foreign], + ]); + const before = structuredClone(foreign); + let signCalls = 0; + let writes = 0; + const tx = { + getPurchase: async () => + claimPurchase(proposalId, PurchaseStatus.PendingPaying), + getToken: async (pub: string) => tokens.get(pub), + upsertToken: async () => { + writes++; + }, + } as unknown as WalletDbTransaction; + const wex = { + runWalletDbTx: async (f: (tx: WalletDbTransaction) => Promise<unknown>) => + f(tx), + cryptoApi: { + signTokenUse: async () => { + signCalls++; + return { sig: "signature" }; + }, + }, + } as unknown as WalletExecutionContext; + + await assert.rejects( + generateTokenSigs( + wex, + proposalId, + "contract-hash", + "wallet-data-hash", + [local.tokenUsePub, foreign.tokenUsePub], + local.merchantBaseUrl, + ), + (err: unknown) => + err instanceof TalerError && + err.errorDetail.code === + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + ); + assert.strictEqual(signCalls, 0); + assert.strictEqual(writes, 0); + assert.deepStrictEqual(foreign, before); + }); + + test(`token signature generation accepts issuer-local retries (cached=${cached})`, async () => { + const proposalId = "current"; + const token = { + tokenUsePub: "local", + tokenUsePriv: "private", + tokenIssuePubHash: "issue-hash", + tokenIssueSig: { signature: "issue-signature" }, + merchantBaseUrl: "https://SHOP.EXAMPLE:443/Proxy/instances/Issuer/", + purchaseId: proposalId, + transactionId: "payment:current", + tokenUseSig: cached + ? { token_pub: "local", token_sig: "cached-signature" } + : undefined, + } as unknown as WalletToken; + let signCalls = 0; + const writes: WalletToken[] = []; + const tx = { + getToken: async () => token, + upsertToken: async (t: WalletToken) => { + writes.push(t); + }, + } as unknown as WalletDbTransaction; + const wex = { + runWalletDbTx: async (f: (tx: WalletDbTransaction) => Promise<unknown>) => + f(tx), + cryptoApi: { + signTokenUse: async () => { + signCalls++; + return { sig: "signature" }; + }, + }, + } as unknown as WalletExecutionContext; + const expectedSig = cached + ? token.tokenUseSig + : { + token_sig: "signature", + token_pub: token.tokenUsePub, + ub_sig: token.tokenIssueSig, + h_issue: token.tokenIssuePubHash, + }; + const sigs = await generateTokenSigs( + wex, + proposalId, + "contract-hash", + "wallet-data-hash", + [token.tokenUsePub], + "https://shop.example/Proxy/instances/issuer/", + ); + assert.deepStrictEqual(sigs, [expectedSig]); + assert.strictEqual(signCalls, cached ? 0 : 1); + assert.deepStrictEqual(writes, [token]); + assert.deepStrictEqual(token.tokenUseSig, expectedSig); + assert.strictEqual(token.transactionId, "payment:current"); + }); +} diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -182,6 +182,8 @@ import { import { selectPayTokensInTx, SelectPayTokensResult, + TokenMerchantVerificationResult, + verifyTokenMerchant, } from "./tokenSelection.js"; import { applyNotifyTransition, @@ -4153,6 +4155,7 @@ async function processPurchasePay( download.contractTermsHash, encodeCrock(hashPayWalletData(wallet_data)), payInfo.payTokenSelection.tokenPubs, + download.contractTerms.merchant_base_url, ); } @@ -4494,6 +4497,7 @@ export async function generateTokenSigs( contractTermsHash: string, walletDataHash: string, tokenPubs: string[], + merchantBaseUrl: string, ): Promise<TokenUseSig[]> { const tokens: WalletToken[] = []; const sigs: TokenUseSig[] = []; @@ -4501,6 +4505,23 @@ export async function generateTokenSigs( for (const pub of tokenPubs) { const token = await tx.getToken(pub); checkDbInvariant(!!token, `token not found for ${pub}`); + // Revalidate persisted selections before signing or reusing signatures. + // A payment prepared by an older wallet may contain foreign tokens. + if ( + verifyTokenMerchant(merchantBaseUrl, token.merchantBaseUrl) !== + TokenMerchantVerificationResult.Automatic + ) { + const purchase = await tx.getPurchase(proposalId); + checkDbInvariant(!!purchase, `purchase not found for ${proposalId}`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(purchase), + debugStateNum: purchase.purchaseStatus, + }, + "selected token was not issued by the payment's merchant", + ); + } tokens.push(token); } }); diff --git a/packages/taler-wallet-core/src/tokenSelection.test.ts b/packages/taler-wallet-core/src/tokenSelection.test.ts @@ -15,6 +15,7 @@ */ import { MerchantContractTokenKind, + TokenAvailabilityHint, TalerProtocolTimestamp, TransactionIdStr, } from "@gnu-taler/taler-util"; @@ -29,153 +30,80 @@ import { 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) - - assert.ok( - verifyTokenMerchant(merchant0, merchant0, { - class: MerchantContractTokenKind.Discount, - expected_domains: [], - }) === TokenMerchantVerificationResult.Automatic, - ); - - assert.ok( - verifyTokenMerchant(merchant1, merchant0, { - class: MerchantContractTokenKind.Discount, - expected_domains: [], - }) === TokenMerchantVerificationResult.Unexpected, - ); - - assert.ok( - verifyTokenMerchant(merchant2, merchant1, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*.taler.Net"], - }) === TokenMerchantVerificationResult.Automatic, - ); - - assert.ok( - verifyTokenMerchant(merchant3, merchant1, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*.taler.net"], - }) === TokenMerchantVerificationResult.Unexpected, - ); - - assert.ok( - verifyTokenMerchant(merchant3, merchant1, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*"], - }) === TokenMerchantVerificationResult.Automatic, - ); - - assert.ok( - verifyTokenMerchant(merchant2, merchant1, { - class: MerchantContractTokenKind.Subscription, - trusted_domains: ["*.taler.net"], - }) === TokenMerchantVerificationResult.Automatic, - ); - - assert.ok( - verifyTokenMerchant(merchant3, merchant1, { - class: MerchantContractTokenKind.Subscription, - trusted_domains: ["*.taler.net"], - }) === TokenMerchantVerificationResult.Untrusted, - ); - - assert.ok( - verifyTokenMerchant(merchant3, merchant1, { - class: MerchantContractTokenKind.Subscription, - trusted_domains: ["*.taler.net"], - }) === TokenMerchantVerificationResult.Untrusted, - ); - - assert.ok( - verifyTokenMerchant(merchant3, merchant1, { - class: MerchantContractTokenKind.Subscription, - trusted_domains: ["*"], - }) === TokenMerchantVerificationResult.Automatic, - ); -}); - -test("a non-wildcard domain only matches that exact host", (t) => { - const merchant = "https://evil.com/"; - const issuer = "https://backend.test.taler.net/"; - - assert.strictEqual( - verifyTokenMerchant(merchant, issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["shop.taler.net"], - }), - TokenMerchantVerificationResult.Unexpected, - ); - - assert.strictEqual( - verifyTokenMerchant(merchant, issuer, { - class: MerchantContractTokenKind.Subscription, - trusted_domains: ["shop.taler.net"], - }), - TokenMerchantVerificationResult.Untrusted, - ); - - assert.strictEqual( - verifyTokenMerchant("https://shop.taler.net/", issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["shop.taler.net"], - }), - TokenMerchantVerificationResult.Automatic, - ); -}); - -test("a wildcard domain only matches at a label boundary", (t) => { - const issuer = "https://backend.test.taler.net/"; - - assert.strictEqual( - verifyTokenMerchant("https://eviltaler.net/", issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*.taler.net"], - }), - TokenMerchantVerificationResult.Unexpected, - ); - +test("token issuer identity normalizes host, instance, and base URL spelling", () => { + const issuer = "https://shop.example/Proxy/instances/myshop/"; + for (const destination of [ + issuer, + "https://SHOP.EXAMPLE/Proxy/instances/myshop/", + "https://shop.example/Proxy/instances/MyShop/", + "https://SHOP.EXAMPLE:443/Proxy/instances/MYSHOP", + "https://shop.example/Proxy/instances/myshop/?unused=1#fragment", + ]) { + assert.strictEqual( + verifyTokenMerchant(destination, issuer), + TokenMerchantVerificationResult.Automatic, + destination, + ); + assert.strictEqual( + verifyTokenMerchant(issuer, destination), + TokenMerchantVerificationResult.Automatic, + destination, + ); + } assert.strictEqual( - verifyTokenMerchant("https://shop.taler.net/", issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*.taler.net"], - }), + verifyTokenMerchant("https://SHOP.EXAMPLE:443", "https://shop.example/"), TokenMerchantVerificationResult.Automatic, ); - assert.strictEqual( - verifyTokenMerchant("https://taler.net/", issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*.taler.net"], - }), + verifyTokenMerchant("http://SHOP.EXAMPLE:80", "http://shop.example/"), TokenMerchantVerificationResult.Automatic, ); }); -test("domains may contain hyphens", (t) => { - const issuer = "https://backend.test.taler.net/"; - - assert.strictEqual( - verifyTokenMerchant("https://shop.my-shop.com/", issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*.my-shop.com"], - }), - TokenMerchantVerificationResult.Automatic, - ); +test("token issuer identity separates instances, origins, and proxy prefixes", () => { + const issuer = "https://shop.example/Proxy/instances/myshop/"; + for (const destination of [ + "https://other.example/Proxy/instances/myshop/", + "https://sub.shop.example/Proxy/instances/myshop/", + "http://shop.example/Proxy/instances/myshop/", + "https://shop.example:8443/Proxy/instances/myshop/", + "https://shop.example/Proxy/instances/other/", + "https://shop.example/Other/instances/myshop/", + "https://shop.example/proxy/instances/myshop/", + "https://shop.example/Proxy/Instances/myshop/", + "https://shop.example/Proxy/", + ]) { + assert.strictEqual( + verifyTokenMerchant(destination, issuer), + TokenMerchantVerificationResult.Untrusted, + destination, + ); + } +}); - assert.strictEqual( - verifyTokenMerchant("https://other.com/", issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["*.my-shop.com"], - }), - TokenMerchantVerificationResult.Unexpected, - ); +test("invalid issuer or destination URLs cannot authorize token spending", () => { + const issuer = "https://shop.example/"; + for (const invalid of [ + "", + "not a URL", + "shop.example", + "https://", + "https://shop.example:invalid/", + "file:///shop.example/", + "ftp://shop.example/", + ]) { + for (const [destination, tokenIssuer] of [ + [invalid, issuer], + [issuer, invalid], + [invalid, invalid], + ]) { + assert.strictEqual( + verifyTokenMerchant(destination, tokenIssuer), + TokenMerchantVerificationResult.Untrusted, + `${destination} / ${tokenIssuer}`, + ); + } + } }); function tokenValidFromTo(fromSec: number, toSec: number): WalletToken { @@ -250,14 +178,130 @@ test("payment repair retains its tokens and excludes other reservations", () => } }); -test("an unparsable issuer domain does not match instead of failing", (t) => { - const issuer = "https://backend.test.taler.net/"; +for (const kind of [ + MerchantContractTokenKind.Discount, + MerchantContractTokenKind.Subscription, +]) { + test(`${kind} domain lists never authorize foreign token spending`, () => { + for (const domains of [ + [], + ["shop.example"], + ["*.example"], + ["*"], + ["invalid"], + ]) { + const token = selectableToken("token"); + token.kind = kind; + token.merchantBaseUrl = "https://issuer.example/instances/Issuer/"; + token.extraData = + kind === MerchantContractTokenKind.Discount + ? { class: kind, expected_domains: domains } + : { class: kind, trusted_domains: domains }; + const candidates = { family: { records: [token], requested: 1 } }; + + const local = selectTokenCandidates( + candidates, + 1, + "https://ISSUER.EXAMPLE/instances/issuer/", + ); + assert.strictEqual(local.type, "success"); + assert.strictEqual(local.details.tokensAvailable, 1); + assert.strictEqual(local.details.tokensUnexpected, 0); + assert.strictEqual(local.details.tokensUntrusted, 0); + + for (const foreign of [ + "https://shop.example/", + "https://issuer.example/instances/competitor/", + ]) { + const result = selectTokenCandidates(candidates, 1, foreign); + assert.strictEqual(result.type, "failure"); + assert.deepStrictEqual(result.details, { + tokensRequested: 1, + tokensAvailable: 0, + tokensUnexpected: 0, + tokensUntrusted: 1, + perTokenFamily: { + family: { + requested: 1, + available: 0, + unexpected: 0, + untrusted: 1, + causeHint: TokenAvailabilityHint.MerchantUntrusted, + }, + }, + }); + } + } + }); +} +test("selection counts foreign tokens but only selects valid local tokens", () => { + const local = selectableToken("local"); + const foreign = { + ...selectableToken("foreign"), + merchantBaseUrl: "https://other.example/", + }; + const expired = { + ...selectableToken("expired"), + validBefore: timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(1)), + }; + const future = { + ...selectableToken("future"), + validAfter: timestampProtocolToDb(TalerProtocolTimestamp.never()), + }; + const reserved = selectableToken("reserved", "payment:other"); + const records = [foreign, expired, future, reserved, local]; + const result = selectTokenCandidates( + { family: { records, requested: 1 } }, + 1, + local.merchantBaseUrl, + ); + assert.strictEqual(result.type, "success"); + if (result.type === "success") { + assert.deepStrictEqual(result.tokens, [local]); + } + assert.strictEqual(result.details.tokensAvailable, 1); + assert.strictEqual(result.details.tokensUntrusted, 1); + assert.strictEqual(result.details.tokensUnexpected, 0); + assert.strictEqual(result.details.perTokenFamily.family.causeHint, undefined); + + const insufficient = selectTokenCandidates( + { family: { records, requested: 2 } }, + 2, + local.merchantBaseUrl, + ); + assert.strictEqual(insufficient.type, "failure"); + assert.strictEqual(insufficient.details.tokensAvailable, 1); assert.strictEqual( - verifyTokenMerchant("https://shop.example/", issuer, { - class: MerchantContractTokenKind.Discount, - expected_domains: ["localhost", "shop."], - }), - TokenMerchantVerificationResult.Unexpected, + insufficient.details.perTokenFamily.family.causeHint, + TokenAvailabilityHint.MerchantUntrusted, + ); +}); + +test("payment repair cannot retain a foreign token reservation", () => { + const retained = selectableToken("retained", "payment:current"); + retained.merchantBaseUrl = "https://foreign.example/"; + const existingReservation = { + transactionId: "payment:current" as TransactionIdStr, + tokenPubs: [retained.tokenUsePub], + }; + const local = selectableToken("local"); + const result = selectTokenCandidates( + { family: { records: [retained, local], requested: 1 } }, + 1, + local.merchantBaseUrl, + existingReservation, + ); + assert.strictEqual(result.type, "success"); + if (result.type === "success") { + assert.deepStrictEqual(result.tokens, [local]); + } + assert.strictEqual(result.details.tokensUntrusted, 1); + const unavailable = selectTokenCandidates( + { family: { records: [retained], requested: 1 } }, + 1, + local.merchantBaseUrl, + existingReservation, ); + assert.strictEqual(unavailable.type, "failure"); }); diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts @@ -16,20 +16,21 @@ import { AbsoluteTime, assertUnreachable, + canonicalizeBaseUrl, + canonicalizeMerchantInstanceUrl, encodeCrock, hashTokenIssuePub, j2s, Logger, MerchantContractInputType, MerchantContractTermsV1, - MerchantContractTokenDetails, - MerchantContractTokenKind, PaymentTokenAvailabilityDetails, TalerError, TalerErrorCode, TalerProtocolTimestamp, TokenAvailabilityHint, TransactionIdStr, + URL, } from "@gnu-taler/taler-util"; import { timestampProtocolFromDb, WalletToken } from "./db/records.js"; import { WalletDbTransaction } from "./db/transaction.js"; @@ -64,123 +65,41 @@ export type SelectPayTokensResult = }; export enum TokenMerchantVerificationResult { - /** - * Merchant is trusted/expected. - * - * Can be used automatically. - */ + /** The merchant issued the token, so it can be used automatically. */ Automatic = "automatic", - /** - * Token used against untrusted merchant. - * - * User should not be allowed to use it. - */ + /** The merchant did not issue the token, so it cannot be used. */ Untrusted = "untrusted-domain", - - /** - * Token used against unexpected merchant. - * - * User should be warned before using. - */ - Unexpected = "unexpected-domain", -} - -/** - * Check whether a host matches a single entry of `trusted_domains' or - * `expected_domains'. - * - * A "*." prefix matches the domain itself and any of its subdomains. The - * match is anchored at a label boundary, so "*.taler.net" covers - * "shop.taler.net" and "taler.net", but not "eviltaler.net". - */ -function matchesTokenDomain(domain: string, host: string): boolean { - if (domain === host) { - return true; - } - if (domain.startsWith("*.")) { - const suffix = domain.slice(2); - return host === suffix || host.endsWith(`.${suffix}`); - } - return false; } /** - * 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). + * Tokens can only be spent at their issuing merchant instance. Domain lists + * in token metadata do not authorize spending at another merchant. */ export function verifyTokenMerchant( merchantBaseUrl: string, tokenMerchantBaseUrl: string, - tokenDetails: MerchantContractTokenDetails, ): TokenMerchantVerificationResult { - const parsedUrl = new URL(merchantBaseUrl); - const merchantDomain = parsedUrl.hostname.toLowerCase(); - - const parsedTokenUrl = new URL(tokenMerchantBaseUrl); - const tokenDomain = parsedTokenUrl.hostname.toLowerCase(); - - const domains: string[] = []; - switch (tokenDetails.class) { - case MerchantContractTokenKind.Discount: - domains.push(...tokenDetails.expected_domains); - break; - case MerchantContractTokenKind.Subscription: - 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); - } - } - - let warning = true; - const regex = new RegExp( - "^(\\*\\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9]([a-z0-9-]*[a-z0-9])?$", - ); - for (let domain of domains) { - domain = domain.toLowerCase(); - if (!regex.test(domain)) { - // The issuer chose the entry; an entry that is not a domain name - // simply matches nothing. - continue; + const normalize = (baseUrl: string): string => { + // Parse first: canonicalizeBaseUrl also accepts schemeless user input, + // whereas these URLs come from contracts and must be absolute HTTP(S). + const url = new URL(baseUrl); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw Error("invalid merchant base URL scheme"); } - if (matchesTokenDomain(domain, merchantDomain)) { - warning = false; - break; - } - } + // Normalize the host and default port even when the instance ID is + // already lower case. Proxy prefixes remain case-sensitive. + return canonicalizeMerchantInstanceUrl(canonicalizeBaseUrl(url.href)); + }; - if (warning) { - switch (tokenDetails.class) { - case MerchantContractTokenKind.Discount: - return TokenMerchantVerificationResult.Unexpected; - case MerchantContractTokenKind.Subscription: - return TokenMerchantVerificationResult.Untrusted; - default: - assertUnreachable(tokenDetails); + try { + if (normalize(merchantBaseUrl) === normalize(tokenMerchantBaseUrl)) { + return TokenMerchantVerificationResult.Automatic; } + } catch { + // Invalid issuer or destination URLs cannot authorize token spending. } - - return TokenMerchantVerificationResult.Automatic; + return TokenMerchantVerificationResult.Untrusted; } export async function selectPayTokensInTx( @@ -294,17 +213,10 @@ export function selectTokenCandidates( .filter((tok) => !isTokenInUse(tok) || isPreviousToken(tok)) .filter((tok) => isTokenValid(tok)) .filter((tok) => { - const res = verifyTokenMerchant( - merchantBaseUrl, - tok.merchantBaseUrl, - tok.extraData, - ); + const res = verifyTokenMerchant(merchantBaseUrl, tok.merchantBaseUrl); switch (res) { case TokenMerchantVerificationResult.Automatic: return true; // usable - case TokenMerchantVerificationResult.Unexpected: - details.perTokenFamily[slug].unexpected += 1; - return true; // usable case TokenMerchantVerificationResult.Untrusted: details.perTokenFamily[slug].untrusted += 1; return false; // non-usable @@ -341,10 +253,6 @@ export function selectTokenCandidates( insufficient = true; details.perTokenFamily[slug].causeHint = hint; continue; - } else { - if (perTokenFamily.unexpected > 0) { - hint = TokenAvailabilityHint.MerchantUnexpected; - } } details.perTokenFamily[slug].causeHint = hint;