taler-typescript-core

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

commit 3ea7037bf6807659ed3b47b65d0590098f1bb652
parent 5aedf2183eb10c06a9e0d4efb9c2e3e60d118624
Author: Florian Dold <dold@taler.net>
Date:   Wed, 12 Aug 2026 14:58:25 +0200

wallet: support Paivana URL payments

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-paivana-repurchase.ts | 162++++++++++---------------------------------------------------------------------
Mpackages/taler-harness/src/integrationtests/test-paivana.ts | 121++++++++++++++-----------------------------------------------------------------
Mpackages/taler-util/src/types-taler-wallet.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/index.ts | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Apackages/taler-wallet-cli/src/paivana.test.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/paivana.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 2+-
Apackages/taler-wallet-core/src/pay-paivana.test.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/pay-paivana.ts | 438+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-template.ts | 12+++++++++---
Mpackages/taler-wallet-core/src/requests.ts | 11+++++++++++
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 1070 insertions(+), 264 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts b/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts @@ -18,25 +18,13 @@ * Imports. */ import { - encodeCrock, - getRandomBytes, Logger, - Result, - stringToBytes, - succeedOrThrow, - TalerMerchantInstanceHttpClient, - TalerPayTemplateUri, - TalerProtocolTimestamp, - TalerUriAction, - TalerUris, - timestampRoundedToBuffer, TransactionMajorState, TransactionMinorState, TransactionType, } from "@gnu-taler/taler-util"; import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { HashSha256 } from "../../../taler-util/src/sha256.js"; import { createSimpleTestkudosEnvironmentV3, withdrawViaBankV3, @@ -48,46 +36,13 @@ const harnessHttpLib = createPlatformHttpLib({ }); export const logger = new Logger("test-paivana.ts"); -export function createPaivanaSessionFor(website: string): { - paivanaId: string; - nonce: string; - time: TalerProtocolTimestamp; -} { - const now = new Date().getTime(); - const cur_time = Math.floor(now / 1000); // + 60*60*24; - const time = TalerProtocolTimestamp.fromSeconds(cur_time); - - const webArr = stringToBytes(`${website}\0`); - const timeArr = timestampRoundedToBuffer(time); - const nonceArr = getRandomBytes(16); - const nonce = encodeCrock(nonceArr); - - const binary = new HashSha256() - .update(nonceArr) - .update(webArr) - .update(timeArr) - .digest(); - - const hash = Buffer.from(binary).toString("base64url"); - - const paivanaId = `${cur_time}-${hash}`; - - return { paivanaId, nonce, time }; -} - export async function runPaivanaRepurchaseTest(t: GlobalTestState) { // Set up test environment - const { - walletClient, - bankClient, - exchange, - merchant, - paivana, - merchantAdminAccessToken, - } = await createSimpleTestkudosEnvironmentV3(t, undefined, { - paivanaWebsite: ".*.html", // block all html pages - }); + const { walletClient, bankClient, exchange, paivana } = + await createSimpleTestkudosEnvironmentV3(t, undefined, { + paivanaWebsite: ".*.html", // block all html pages + }); const withdrawalRes = await withdrawViaBankV3(t, { walletClient, @@ -100,43 +55,14 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { const website = `${paivana.baseUrl}index.html`; - const firstRequest = await harnessHttpLib.fetch(website); - const templateURI = firstRequest.headers.get("paivana"); - t.assertTrue(!!templateURI); - - const uri = Result.unpack(TalerUris.parse(templateURI)); - t.assertTrue(uri.type === TalerUriAction.PayTemplate); - - const merchantClient = new TalerMerchantInstanceHttpClient( - merchant.makeInstanceBaseUrl(), - ); - let times = 3; while (times--) { - const session = createPaivanaSessionFor(website); - logger.info("1) PAIVANA ID created", JSON.stringify(session)); - - logger.info("2) access denied, we need to pay"); + logger.info("1) access denied, preparing Paivana payment"); { - // Pay the access to the site - // This is part of the wallet and it may be a - // thrid device so no information produced - // here is available - - const newTemplate: TalerPayTemplateUri = { - type: TalerUriAction.PayTemplate, - merchantBaseUrl: uri.merchantBaseUrl, - templateId: uri.templateId, - fulfillmentUrl: website, - sessionId: session.paivanaId, - }; - const talerPayTemplateUri = TalerUris.stringify(newTemplate); - logger.info("3) pay template", newTemplate, talerPayTemplateUri); - const templateStatus = await walletClient.call( - WalletApiOperation.PreparePayForTemplateV2, - { talerPayTemplateUri }, + WalletApiOperation.PreparePayForPaivana, + { url: website }, ); await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: templateStatus.transactionId, @@ -160,8 +86,6 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { ); t.assertDeepEqual(txDet.type, TransactionType.Payment); - let orderId: string; - if (txDet.txState.major === TransactionMajorState.Failed) { const repurchaseTxId = txDet.repurchaseTransactionId; @@ -176,24 +100,11 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { }, }, ); - - const repurchaseTxDet = await walletClient.call( - WalletApiOperation.GetTransactionById, - { - transactionId: repurchaseTxId, - }, - ); - t.assertDeepEqual(repurchaseTxDet.type, TransactionType.Payment); - t.assertTrue(!!repurchaseTxDet.info); - orderId = repurchaseTxDet.info.orderId; } else { await walletClient.call(WalletApiOperation.ConfirmPay, { transactionId: txDet.transactionId, choiceIndex: 0, }); - t.assertTrue(!!txDet.info); - orderId = txDet.info.orderId; - await walletClient.call( WalletApiOperation.TestingWaitTransactionState, { @@ -205,56 +116,21 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { ); } - const orderStatus = succeedOrThrow( - await merchantClient.getOrderDetails( - merchantAdminAccessToken, - orderId, - { - sessionId: session.paivanaId, - }, - ), + const cookieResult = await walletClient.call( + WalletApiOperation.GetPaivanaCookie, + { + transactionId: templateStatus.transactionId, + paivana: templateStatus.paivana, + }, ); - console.log("asdasdasd", orderStatus); - // check that merchant also think is paid for this session - t.assertTrue(orderStatus.order_status === "paid"); + t.assertTrue(cookieResult.cookie.startsWith("Paivana-Cookie=")); + const protectedResponse = await harnessHttpLib.fetch(website, { + headers: { Cookie: cookieResult.cookie }, + }); + t.assertTrue(protectedResponse.status === 200); + t.assertTrue((await protectedResponse.bytes()).byteLength > 0); } - logger.info("6) getting the order based on session and site"); - - const order = succeedOrThrow( - await merchantClient.getOrderIdForSessionAndUrl( - session.paivanaId, - website, - ), - ); - - logger.info(`---- STATE ${website}`, { - order_id: order.order_id, - nonce: session.nonce, - cur_time: session.time, - website, - }); - - logger.info("7) showing the info to paivana so it will return the cookie"); - const res = await harnessHttpLib.fetch( - `${paivana.baseUrl}.well-known/paivana`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: { - order_id: order.order_id, - nonce: session.nonce, - cur_time: session.time, - website, - }, - redirect: "manual", - }, - ); - - t.assertTrue(res.headers.get("location") === website); - t.assertTrue(!!res.headers.get("Set-Cookie")); - t.assertTrue(res.status === 303); - await waitMs(600); } } diff --git a/packages/taler-harness/src/integrationtests/test-paivana.ts b/packages/taler-harness/src/integrationtests/test-paivana.ts @@ -19,15 +19,8 @@ */ import { Logger, - Result, - succeedOrThrow, - TalerMerchantInstanceHttpClient, - TalerUri, - TalerUriAction, - TalerUris, TransactionMajorState, TransactionType, - WalletNotification, } from "@gnu-taler/taler-util"; import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -36,7 +29,6 @@ import { withdrawViaBankV3, } from "../harness/environments.js"; import { GlobalTestState } from "../harness/harness.js"; -import { createPaivanaSessionFor } from "./test-paivana-repurchase.js"; const harnessHttpLib = createPlatformHttpLib({ enableThrottling: false, @@ -46,22 +38,10 @@ export const logger = new Logger("test-paivana.ts"); export async function runPaivanaTest(t: GlobalTestState) { // Set up test environment - const { - walletClient, - bankClient, - exchange, - merchant, - paivana, - merchantAdminAccessToken, - } = await createSimpleTestkudosEnvironmentV3(t, undefined, { - paivanaWebsite: ".*.html", // block all html pages - }); - - const notifs: WalletNotification[] = []; - - walletClient.addNotificationListener((x) => { - notifs.push(x); - }); + const { walletClient, bankClient, exchange, paivana } = + await createSimpleTestkudosEnvironmentV3(t, undefined, { + paivanaWebsite: ".*.html", // block all html pages + }); const withdrawalRes = await withdrawViaBankV3(t, { walletClient, @@ -74,43 +54,12 @@ export async function runPaivanaTest(t: GlobalTestState) { const website = `${paivana.baseUrl}index.html`; - const firstRequest = await harnessHttpLib.fetch(website); - const templateURI = firstRequest.headers.get("paivana"); - t.assertTrue(!!templateURI); - - const uri = Result.unpack(TalerUris.parse(templateURI)); - t.assertTrue(uri.type === TalerUriAction.PayTemplate); - - const merchantClient = new TalerMerchantInstanceHttpClient( - merchant.makeInstanceBaseUrl(), - ); - - const session = createPaivanaSessionFor(website); - - logger.info("1) PAIVANA ID created", JSON.stringify(session)); - - logger.info("2) access denied, we need to pay"); + logger.info("1) access denied, preparing Paivana payment"); { - // Pay the access to the site - // This is part of the wallet and it may be a - // thrid device so no information produced - // here is available - - const newTemplate: TalerUri = { - type: TalerUriAction.PayTemplate, - merchantBaseUrl: uri.merchantBaseUrl, - templateId: uri.templateId, - fulfillmentUrl: website, - sessionId: session.paivanaId, - }; - - const talerPayTemplateUri = TalerUris.stringify(newTemplate); - logger.info("3) pay template", newTemplate, talerPayTemplateUri); - const templateStatus = await walletClient.call( - WalletApiOperation.PreparePayForTemplateV2, - { talerPayTemplateUri }, + WalletApiOperation.PreparePayForPaivana, + { url: website }, ); await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: templateStatus.transactionId, @@ -138,51 +87,23 @@ export async function runPaivanaTest(t: GlobalTestState) { t.assertDeepEqual(paymentTx.txState.major, TransactionMajorState.Done); t.assertTrue(paymentTx.type === TransactionType.Payment); t.assertTrue(paymentTx.contractTerms != null); - logger.info("5) order paid", paymentTx.contractTerms.fulfillment_url); - - const orderStatus = succeedOrThrow( - await merchantClient.getOrderDetails( - merchantAdminAccessToken, - paymentTx.contractTerms.order_id, - { - sessionId: session.paivanaId, - }, - ), - ); - // check that merchant also think is paid for this session - t.assertTrue(orderStatus.order_status === "paid"); - } - - logger.info("6) getting the order based on session and site"); - - const order = succeedOrThrow( - await merchantClient.getOrderIdForSessionAndUrl(session.paivanaId, website), - ); - - logger.info(`---- STATE ${website}`, { - order_id: order.order_id, - nonce: session.nonce, - cur_time: session.time, - website, - }); + logger.info("2) order paid", paymentTx.contractTerms.fulfillment_url); - const res = await harnessHttpLib.fetch( - `${paivana.baseUrl}.well-known/paivana`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: { - order_id: order.order_id, - nonce: session.nonce, - cur_time: session.time, - website, + const cookieResult = await walletClient.call( + WalletApiOperation.GetPaivanaCookie, + { + transactionId: templateStatus.transactionId, + paivana: templateStatus.paivana, }, - redirect: "manual", - }, - ); + ); + t.assertTrue(cookieResult.cookie.startsWith("Paivana-Cookie=")); - t.assertTrue(res.headers.get("location") === website); - t.assertTrue(res.status === 303); + const protectedResponse = await harnessHttpLib.fetch(website, { + headers: { Cookie: cookieResult.cookie }, + }); + t.assertTrue(protectedResponse.status === 200); + t.assertTrue((await protectedResponse.bytes()).byteLength > 0); + } } runPaivanaTest.suites = ["wallet"]; diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1094,6 +1094,72 @@ export interface PreparePayV2Result { transactionId: TransactionIdStr; } +/** Information needed to redeem a paid Paivana order for an access cookie. */ +export interface PaivanaRedemption { + /** Canonical HTTP(S) URL of the protected resource. */ + url: string; + + /** Crockford-base32 encoded 16-byte client nonce. */ + nonce: string; + + /** End of the access period used to derive the Paivana session ID. */ + expiration: TalerProtocolTimestamp; +} + +export interface PreparePayForPaivanaRequest { + url: string; + progressToken?: string; +} + +export interface PreparePayForPaivanaResult { + transactionId: TransactionIdStr; + paivana: PaivanaRedemption; +} + +export interface GetPaivanaCookieRequest { + transactionId: TransactionIdStr; + paivana: PaivanaRedemption; +} + +export interface GetPaivanaCookieResult { + /** Plain Cookie request-header value, without Set-Cookie attributes. */ + cookie: string; +} + +export const codecForPaivanaRedemption = (): Codec<PaivanaRedemption> => + buildCodecForObject<PaivanaRedemption>() + .property("url", codecForString()) + .property("nonce", codecForString()) + .property("expiration", codecForTimestamp) + .build("PaivanaRedemption"); + +export const codecForPreparePayForPaivanaRequest = + (): Codec<PreparePayForPaivanaRequest> => + buildCodecForObject<PreparePayForPaivanaRequest>() + .property("url", codecForString()) + .property("progressToken", codecOptional(codecForString())) + .build("PreparePayForPaivanaRequest"); + +export const codecForPreparePayForPaivanaResult = + (): Codec<PreparePayForPaivanaResult> => + buildCodecForObject<PreparePayForPaivanaResult>() + .property("transactionId", codecForTransactionIdStr()) + .property("paivana", codecForPaivanaRedemption()) + .build("PreparePayForPaivanaResult"); + +export const codecForGetPaivanaCookieRequest = + (): Codec<GetPaivanaCookieRequest> => + buildCodecForObject<GetPaivanaCookieRequest>() + .property("transactionId", codecForTransactionIdStr()) + .property("paivana", codecForPaivanaRedemption()) + .build("GetPaivanaCookieRequest"); + +export const codecForGetPaivanaCookieResult = + (): Codec<GetPaivanaCookieResult> => + buildCodecForObject<GetPaivanaCookieResult>() + .property("cookie", codecForString()) + .build("GetPaivanaCookieResult"); + export interface BankWithdrawDetails { status: WithdrawalOperationStatusFlag; currency: string; diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -108,8 +108,15 @@ import { formatPrettyTransaction } from "./transactions-pretty.js"; import { formatPrettyBalance } from "./balance-pretty.js"; import { formatPrettyBankAccount } from "./bank-accounts-pretty.js"; import { formatPrettyExchange } from "./exchanges-pretty.js"; +import { + makePaivanaCurlCommand, + paivanaBodyFilename, + parsePaivanaOutputMode, + selectAvailableFilename, +} from "./paivana.js"; import * as fs from "node:fs"; +import * as readline from "node:readline"; // This module also serves as the entry point for the crypto // thread worker, and thus must expose these two handlers. @@ -185,6 +192,16 @@ interface PayOptions { verbose?: number; } +let machineSafeStdout = false; + +function diagnosticLog(...args: unknown[]): void { + if (machineSafeStdout) { + console.error(...args); + } else { + console.log(...args); + } +} + async function doHandlePayTransaction( ctx: WalletContext, transactionId: TransactionIdStr, @@ -202,28 +219,28 @@ async function doHandlePayTransaction( throw Error("unexpected transaction type"); } if (paySt.txState.major === TransactionMajorState.Done) { - console.log(`Payment succeeded (already done).`); + diagnosticLog(`Payment succeeded (already done).`); return; } if (paySt.txState.major === TransactionMajorState.Expired) { - console.log(`Payment expired.`); + diagnosticLog(`Payment expired.`); return; } if (paySt.txState.major === TransactionMajorState.Aborted) { - console.log(`Payment aborted.`); + diagnosticLog(`Payment aborted.`); return; } if (paySt.txState.major === TransactionMajorState.Finalizing) { - console.log(`Payment finalizing (${paySt.txState.minor})`); + diagnosticLog(`Payment finalizing (${paySt.txState.minor})`); return; } if ( paySt.txState.major === TransactionMajorState.Failed && paySt.txState.minor === TransactionMinorState.Repurchase ) { - console.log(`Repurchase detected (${[paySt.repurchaseTransactionId]})`); + diagnosticLog(`Repurchase detected (${[paySt.repurchaseTransactionId]})`); if (paySt.repurchaseTransactionId != null) { - console.log(`Waiting for old transaction to be final.`); + diagnosticLog(`Waiting for old transaction to be final.`); await wallet.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: paySt.repurchaseTransactionId, txState: { @@ -231,12 +248,12 @@ async function doHandlePayTransaction( minor: "*", }, }); - console.log(`Transaction done.`); + diagnosticLog(`Transaction done.`); } return; } if (paySt.txState.major === TransactionMajorState.Failed) { - console.log(`Payment failed.`); + diagnosticLog(`Payment failed.`); return; } if (paySt.txState.major === TransactionMajorState.Dialog) { @@ -253,7 +270,7 @@ async function doHandlePayTransaction( if (options.noWait) { return; } - console.log(`Waiting for transaction '${transactionId}' to finish`); + diagnosticLog(`Waiting for transaction '${transactionId}' to finish`); await wallet.call(WalletApiOperation.TestingWaitTransactionState, { transactionId, txState: "nonpending", @@ -261,7 +278,7 @@ async function doHandlePayTransaction( const tx = await wallet.call(WalletApiOperation.GetTransactionById, { transactionId, }); - console.log(`Finished with status '${tx.txState.major}'.`); + diagnosticLog(`Finished with status '${tx.txState.major}'.`); } } @@ -287,6 +304,83 @@ async function doPay( await doHandlePayTransaction(ctx, r.transactionId, options); } +async function doPaivana( + ctx: WalletContext, + url: string, + modeString: string, + options: PayOptions, +): Promise<void> { + let mode; + try { + mode = parsePaivanaOutputMode(modeString); + } catch (e) { + throw new CliUsageError(e instanceof Error ? e.message : String(e)); + } + + machineSafeStdout = true; + try { + const prepared = await ctx.client.call( + WalletApiOperation.PreparePayForPaivana, + { url }, + ); + await doHandlePayTransaction(ctx, prepared.transactionId, options); + const { cookie } = await ctx.client.call( + WalletApiOperation.GetPaivanaCookie, + { + transactionId: prepared.transactionId, + paivana: prepared.paivana, + }, + ); + + switch (mode.type) { + case "curl": + console.log(makePaivanaCurlCommand(cookie, prepared.paivana.url)); + return; + case "cookie": + console.log(cookie); + return; + case "body": { + const http = createPlatformHttpLib({ enableThrottling: false }); + const response = await http.fetch(prepared.paivana.url, { + headers: { Cookie: cookie }, + redirect: "follow", + }); + if (response.status < 200 || response.status >= 300) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + { + requestUrl: response.requestUrl, + requestMethod: response.requestMethod, + httpStatusCode: response.status, + }, + `protected resource returned HTTP status ${response.status}`, + ); + } + const bytes = await response.bytes(); + if (mode.path === "-") { + await new Promise<void>((resolve, reject) => { + process.stdout.write(bytes, (err) => + err ? reject(err) : resolve(), + ); + }); + return; + } + const path = + mode.path ?? + selectAvailableFilename( + paivanaBodyFilename(prepared.paivana.url), + fs.existsSync, + ); + fs.writeFileSync(path, bytes); + diagnosticLog(`Saved protected resource to '${path}'.`); + return; + } + } + } finally { + machineSafeStdout = false; + } +} + let globalNonInteractive = false; let globalInteractive = false; @@ -334,7 +428,19 @@ async function promptOrFail(question: string): Promise<string> { ); processExit(EXIT_INPUT_REQUIRED); } - return await readlinePrompt(question); + if (!machineSafeStdout) { + return await readlinePrompt(question); + } + const prompt = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + return await new Promise<string>((resolve) => { + prompt.question(question, (answer) => { + prompt.close(); + resolve(answer); + }); + }); } async function askChoice(n: number): Promise<number> { @@ -345,7 +451,7 @@ async function askChoice(n: number): Promise<number> { if (choice >= 0 && choice < n) { return choice; } else { - console.log("Please enter a valid choice."); + diagnosticLog("Please enter a valid choice."); } } } @@ -358,7 +464,7 @@ async function askYesNo(question: string = "Pay?"): Promise<boolean> { } else if (yesNoResp === "n" || yesNoResp === "no") { return false; } else { - console.log("please answer y/n"); + diagnosticLog("please answer y/n"); } } } @@ -1715,11 +1821,11 @@ async function performConfirmPayment( console.error(j2s(choices)); return { performed: false, exitCode: EXIT_INPUT_REQUIRED }; } - console.log(`${j2s(choices)}`); + diagnosticLog(`${j2s(choices)}`); choiceIndex = await askChoice(choices.choices.length); } else { choiceIndex = 0; - console.log("contract:", choices.contractTerms); + diagnosticLog("contract:", choices.contractTerms); } const myChoice = choices.choices[choiceIndex]; if (myChoice == null) { @@ -1736,7 +1842,7 @@ async function performConfirmPayment( case "input-required": return NEEDS_INPUT; case "no": - console.log("not paying"); + diagnosticLog("not paying"); return NOT_PERFORMED; } await ctx.client.call(WalletApiOperation.ConfirmPay, { @@ -2500,7 +2606,7 @@ async function cliPeerPushCredit( walletCli .subcommand("handleUri", "handle-uri", { - help: "Handle a taler:// URI.", + help: "Handle a taler:// URI or an HTTP(S) Paivana URL.", }) .maybeArgument("uri", clk.STRING) .maybeOption("withdrawalExchange", ["--withdrawal-exchange"], clk.STRING, { @@ -2508,6 +2614,9 @@ walletCli }) .flag("noWait", ["--no-wait"]) .maybeOption("choiceIndex", ["--choice-index"], clk.INT) + .maybeOption("paivana", ["--paivana"], clk.STRING, { + help: "Paivana output: curl, cookie, body, body:-, or body:PATH.", + }) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) .flag("nonInteractive", ["--non-interactive"], { help: "Deprecated, use the global --non-interactive.", @@ -2525,6 +2634,31 @@ walletCli } else { uri = await promptOrFail("Taler URI: "); } + let httpUrl = false; + try { + const parsed = new URL(uri); + httpUrl = parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + // It might still be a Taler URI, parsed below. + } + if (httpUrl) { + if (args.handleUri.noWait) { + throw new CliUsageError( + "--no-wait cannot be used with a Paivana URL", + ); + } + await doPaivana(wallet, uri, args.handleUri.paivana ?? "curl", { + alwaysYes: args.handleUri.autoYes, + choiceIndex: args.handleUri.choiceIndex, + nonInteractive: args.handleUri.nonInteractive || isNonInteractive(), + }); + return; + } + if (args.handleUri.paivana !== undefined) { + throw new CliUsageError( + "--paivana can only be used with an HTTP(S) URL", + ); + } const parsedTalerUri = Result.orUndefined(TalerUris.parse(uri)); if (!parsedTalerUri) { throw Error("invalid taler URI"); diff --git a/packages/taler-wallet-cli/src/paivana.test.ts b/packages/taler-wallet-cli/src/paivana.test.ts @@ -0,0 +1,61 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +import assert from "node:assert"; +import { test } from "node:test"; +import { + makePaivanaCurlCommand, + paivanaBodyFilename, + parsePaivanaOutputMode, + selectAvailableFilename, +} from "./paivana.js"; + +test("Paivana modes are parsed strictly", () => { + assert.deepStrictEqual(parsePaivanaOutputMode("curl"), { type: "curl" }); + assert.deepStrictEqual(parsePaivanaOutputMode("cookie"), { type: "cookie" }); + assert.deepStrictEqual(parsePaivanaOutputMode("body"), { type: "body" }); + assert.deepStrictEqual(parsePaivanaOutputMode("body:-"), { + type: "body", + path: "-", + }); + assert.deepStrictEqual(parsePaivanaOutputMode("body:out.bin"), { + type: "body", + path: "out.bin", + }); + assert.throws(() => parsePaivanaOutputMode("body:")); + assert.throws(() => parsePaivanaOutputMode("json")); +}); + +test("curl output quotes the cookie and URL for a POSIX shell", () => { + assert.strictEqual( + makePaivanaCurlCommand( + "Paivana-Cookie=a'b;$HOME", + "https://example.com/a b?q='x'&run=$(false)", + ), + "curl --location --cookie 'Paivana-Cookie=a'\"'\"'b;$HOME' 'https://example.com/a b?q='\"'\"'x'\"'\"'&run=$(false)'", + ); +}); + +test("bare body mode follows wget-style URL naming", () => { + assert.strictEqual(paivanaBodyFilename("https://example.com/a.bin"), "a.bin"); + assert.strictEqual(paivanaBodyFilename("https://example.com/"), "index.html"); + assert.strictEqual( + paivanaBodyFilename("https://example.com/path/?x=1&y=2"), + "index.html?x=1&y=2", + ); + assert.strictEqual( + paivanaBodyFilename("https://example.com/path/file?download=yes"), + "file?download=yes", + ); +}); + +test("bare body mode does not overwrite existing files", () => { + const existing = new Set(["file", "file.1", "file.2"]); + assert.strictEqual( + selectAvailableFilename("file", (x) => existing.has(x)), + "file.3", + ); +}); diff --git a/packages/taler-wallet-cli/src/paivana.ts b/packages/taler-wallet-cli/src/paivana.ts @@ -0,0 +1,54 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +export type PaivanaOutputMode = + | { type: "curl" } + | { type: "cookie" } + | { type: "body"; path?: string }; + +export function parsePaivanaOutputMode(mode: string): PaivanaOutputMode { + if (mode === "curl") return { type: "curl" }; + if (mode === "cookie") return { type: "cookie" }; + if (mode === "body") return { type: "body" }; + if (mode === "body:-") return { type: "body", path: "-" }; + if (mode.startsWith("body:") && mode.length > "body:".length) { + return { type: "body", path: mode.substring("body:".length) }; + } + throw Error( + `invalid Paivana output mode '${mode}' (expected curl, cookie, body, body:-, or body:PATH)`, + ); +} + +/** Quote one argument for a POSIX shell without permitting interpolation. */ +export function posixShellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +export function makePaivanaCurlCommand(cookie: string, url: string): string { + return `curl --location --cookie ${posixShellQuote(cookie)} ${posixShellQuote(url)}`; +} + +/** Wget-style Unix filename based only on the originally requested URL. */ +export function paivanaBodyFilename(url: string): string { + const parsed = new URL(url); + const slash = parsed.pathname.lastIndexOf("/"); + const component = parsed.pathname.substring(slash + 1) || "index.html"; + return component + parsed.search; +} + +export function selectAvailableFilename( + wanted: string, + exists: (path: string) => boolean, +): string { + if (!exists(wanted)) return wanted; + for (let suffix = 1; ; suffix++) { + const candidate = `${wanted}.${suffix}`; + if (!exists(candidate)) return candidate; + } +} diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1480,7 +1480,7 @@ async function generateSlate( * record for the provided arguments already exists, * return the old proposal ID. */ -async function createOrReusePurchase( +export async function createOrReusePurchase( wex: WalletExecutionContext, merchantBaseUrl: string, orderId: string, diff --git a/packages/taler-wallet-core/src/pay-paivana.test.ts b/packages/taler-wallet-core/src/pay-paivana.test.ts @@ -0,0 +1,191 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + CancellationToken, + encodeCrock, + TalerProtocolTimestamp, + TemplateType, +} from "@gnu-taler/taler-util"; +import type { HttpResponse } from "@gnu-taler/taler-util/http"; +import assert from "node:assert"; +import { test } from "node:test"; +import { + canonicalizePaivanaUrl, + encodePaivanaExpiration, + extractPaivanaCookie, + getPaivanaExpiration, + makePaivanaSessionId, + parsePaivanaTemplateUri, + preparePaivanaTemplate, + requirePaivanaTemplate, + validatePaivanaRedemption, +} from "./pay-paivana.js"; +import type { WalletExecutionContext } from "./wallet.js"; + +function response(status: number, paivanaHeader?: string): HttpResponse { + return { + requestUrl: "https://example.com/protected", + requestMethod: "GET", + status, + headers: { + get(name: string): string | null { + return name.toLowerCase() === "paivana" + ? (paivanaHeader ?? null) + : null; + }, + set(): void {}, + toJSON(): unknown { + return {}; + }, + }, + json: async () => ({}), + text: async () => "", + bytes: async () => new Uint8Array(), + }; +} + +function discoveryWex(result: HttpResponse): WalletExecutionContext { + return { + cancellationToken: CancellationToken.CONTINUE, + http: { fetch: async () => result }, + } as unknown as WalletExecutionContext; +} + +test("Paivana session ID matches the DD 76 input encoding", () => { + const nonce = Uint8Array.from({ length: 16 }, (_, i) => i); + const expiration = TalerProtocolTimestamp.fromSeconds(1_700_000_000); + assert.strictEqual( + Buffer.from(encodePaivanaExpiration(expiration)).toString("hex"), + "00060a24181e4000", + ); + assert.strictEqual( + makePaivanaSessionId( + expiration, + nonce, + "https://example.com/protected?x=1", + ), + "1700000000-_NNJFY9s7TSotSb9HarGwngYN0NzPOz11Y8jZVHYGVg", + ); +}); + +test("Paivana expiration uses max_pickup_duration and caps it at 100 years", () => { + const now = TalerProtocolTimestamp.fromSeconds(1234); + assert.deepStrictEqual( + getPaivanaExpiration({ d_us: 2_500_000 }, now), + TalerProtocolTimestamp.fromSeconds(1236), + ); + const cap = 60 * 60 * 24 * 365 * 100; + assert.deepStrictEqual( + getPaivanaExpiration({ d_us: "forever" }, now), + TalerProtocolTimestamp.fromSeconds(1234 + cap), + ); + assert.deepStrictEqual( + getPaivanaExpiration({ d_us: (cap + 500) * 1_000_000 }, now), + TalerProtocolTimestamp.fromSeconds(1234 + cap), + ); +}); + +test("Paivana URLs must be HTTP(S) and are canonicalized", () => { + assert.strictEqual( + canonicalizePaivanaUrl("https://example.com/a#client-only"), + "https://example.com/a", + ); + assert.throws(() => canonicalizePaivanaUrl("not a URL")); + assert.throws(() => canonicalizePaivanaUrl("file:///tmp/secret")); + assert.throws(() => canonicalizePaivanaUrl("https://user@example.com/a")); +}); + +test("Paivana cookie extraction rejects attributes and unrelated cookies", () => { + assert.strictEqual( + extractPaivanaCookie( + "Paivana-Cookie=1700000000.sig; Path=/protected; HttpOnly; SameSite=Strict", + ), + "Paivana-Cookie=1700000000.sig", + ); + assert.throws(() => extractPaivanaCookie(null)); + assert.throws(() => extractPaivanaCookie("session=wrong; Path=/")); + assert.throws(() => extractPaivanaCookie("Paivana-Cookie=bad value; Path=/")); +}); + +test("Paivana header must advertise a pay-template URI", () => { + assert.strictEqual(parsePaivanaTemplateUri("not a Taler URI"), undefined); + assert.strictEqual( + parsePaivanaTemplateUri("taler://pay/example.com/order"), + undefined, + ); + assert.strictEqual( + parsePaivanaTemplateUri( + "taler://pay-template/example.com/merchant/default/template/access", + )?.type, + "pay-template", + ); +}); + +test("Paivana discovery requires a 402 and a valid Paivana header", async () => { + await assert.rejects(() => + preparePaivanaTemplate(discoveryWex(response(200)), { + url: "https://example.com/protected", + }), + ); + await assert.rejects(() => + preparePaivanaTemplate(discoveryWex(response(402)), { + url: "https://example.com/protected", + }), + ); + await assert.rejects(() => + preparePaivanaTemplate(discoveryWex(response(402, "not-a-taler-uri")), { + url: "https://example.com/protected", + }), + ); +}); + +test("only Paivana templates can be prepared from a Paivana URL", () => { + assert.throws(() => + requirePaivanaTemplate({ + template_contract: { + template_type: TemplateType.FIXED_ORDER, + amount: "TESTKUDOS:1", + }, + }), + ); + assert.doesNotThrow(() => + requirePaivanaTemplate({ + template_contract: { + template_type: TemplateType.PAIVANA, + choices: [{ amount: "TESTKUDOS:1" }], + }, + }), + ); +}); + +test("Paivana redemption metadata is validated and tied to its session", () => { + const nonce = Uint8Array.from({ length: 16 }, (_, i) => i); + const redemption = { + url: "https://example.com/protected", + nonce: encodeCrock(nonce), + expiration: TalerProtocolTimestamp.fromSeconds(1_700_000_000), + }; + assert.strictEqual( + validatePaivanaRedemption(redemption).sessionId, + makePaivanaSessionId(redemption.expiration, nonce, redemption.url), + ); + assert.throws(() => + validatePaivanaRedemption({ ...redemption, nonce: "TOO-SHORT" }), + ); + assert.throws(() => + validatePaivanaRedemption({ ...redemption, url: `${redemption.url}#x` }), + ); + assert.throws(() => + validatePaivanaRedemption({ + ...redemption, + expiration: TalerProtocolTimestamp.never(), + }), + ); +}); diff --git a/packages/taler-wallet-core/src/pay-paivana.ts b/packages/taler-wallet-core/src/pay-paivana.ts @@ -0,0 +1,438 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + base64FromArrayBuffer, + decodeCrock, + encodeCrock, + GetPaivanaCookieRequest, + GetPaivanaCookieResult, + PaivanaRedemption, + PreparePayForPaivanaRequest, + PreparePayForPaivanaResult, + Result, + sha256, + stringToBytes, + succeedOrThrow, + TalerError, + TalerErrorCode, + TalerMerchantInstanceHttpClient, + TalerPayTemplateUri, + TalerProtocolTimestamp, + TalerUriAction, + TalerUris, + TemplateType, + timestampRoundedToBuffer, + TransactionType, + WalletTemplateDetailsResponse, + getRandomBytes, +} from "@gnu-taler/taler-util"; +import { readTalerErrorResponse } from "@gnu-taler/taler-util/http"; +import { PurchaseStatus } from "./db-common.js"; +import { + computePayMerchantTransactionState, + createOrReusePurchase, +} from "./pay-merchant.js"; +import { instantiateTemplateRaw } from "./pay-template.js"; +import { + runWithMaybeProgressContext, + runWithProgressRetries, +} from "./progress.js"; +import { + makeInvalidTransactionIdError, + makeTransactionNotFoundError, + parseTransactionIdentifier, +} from "./transactions.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const MAX_PAIVANA_PICKUP_SECONDS = 60 * 60 * 24 * 365 * 100; + +function malformedPaivanaResponse( + requestUrl: string, + requestMethod: string, + httpStatusCode: number, + hint: string, +): TalerError { + return TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { requestUrl, requestMethod, httpStatusCode }, + hint, + ); +} + +/** Validate and canonicalize a protected Paivana resource URL. */ +export function canonicalizePaivanaUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "url" }, + `invalid Paivana URL "${url}"`, + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "url" }, + "Paivana payments require an HTTP(S) URL", + ); + } + if (parsed.username !== "" || parsed.password !== "") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "url" }, + "Paivana URLs must not contain credentials", + ); + } + // Fragments are not sent in HTTP requests and therefore cannot be part of + // the URL reconstructed by the Paivana reverse proxy. + parsed.hash = ""; + return parsed.href; +} + +/** Encode a Paivana expiration as an unsigned big-endian microsecond value. */ +export function encodePaivanaExpiration( + expiration: TalerProtocolTimestamp, +): Uint8Array { + if (expiration.t_s === "never") { + throw Error("a Paivana expiration must be finite"); + } + return timestampRoundedToBuffer(expiration); +} + +/** Derive the DD 76 session identifier from its protocol inputs. */ +export function makePaivanaSessionId( + expiration: TalerProtocolTimestamp, + nonce: Uint8Array, + url: string, +): string { + if (expiration.t_s === "never") { + throw Error("a Paivana expiration must be finite"); + } + if (nonce.byteLength !== 16) { + throw Error("a Paivana nonce must contain exactly 16 bytes"); + } + const urlBytes = stringToBytes(`${url}\0`); + const expirationBytes = encodePaivanaExpiration(expiration); + const input = new Uint8Array( + nonce.byteLength + urlBytes.byteLength + expirationBytes.byteLength, + ); + input.set(nonce, 0); + input.set(urlBytes, nonce.byteLength); + input.set(expirationBytes, nonce.byteLength + urlBytes.byteLength); + const digest = base64FromArrayBuffer(sha256(input)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${expiration.t_s}-${digest}`; +} + +/** Compute a finite access expiration, with the browser client's 100y cap. */ +export function getPaivanaExpiration( + maxPickupDuration: { d_us: number | "forever" } | undefined, + now: TalerProtocolTimestamp = TalerProtocolTimestamp.now(), +): TalerProtocolTimestamp { + if (now.t_s === "never") { + throw Error("current time must be finite"); + } + const pickupSeconds = + maxPickupDuration?.d_us === undefined || + maxPickupDuration.d_us === "forever" + ? MAX_PAIVANA_PICKUP_SECONDS + : Math.min( + MAX_PAIVANA_PICKUP_SECONDS, + Math.floor(maxPickupDuration.d_us / 1_000_000), + ); + return TalerProtocolTimestamp.fromSeconds(now.t_s + pickupSeconds); +} + +export interface PreparedPaivanaTemplate { + request: PreparePayForPaivanaRequest; + redemption: PaivanaRedemption; + talerPayTemplateUri: string; + templateInfo: WalletTemplateDetailsResponse; +} + +export function parsePaivanaTemplateUri( + advertisedUri: string, +): TalerPayTemplateUri | undefined { + return Result.orUndefined( + TalerUris.parseRestricted(advertisedUri, TalerUriAction.PayTemplate), + ); +} + +export function requirePaivanaTemplate( + templateInfo: WalletTemplateDetailsResponse, +): void { + if (templateInfo.template_contract.template_type !== TemplateType.PAIVANA) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + {}, + "the template advertised by the Paivana paywall is not a Paivana template", + ); + } +} + +/** Discover and validate the payment template advertised by a paywall. */ +export async function preparePaivanaTemplate( + wex: WalletExecutionContext, + request: PreparePayForPaivanaRequest, +): Promise<PreparedPaivanaTemplate> { + const url = canonicalizePaivanaUrl(request.url); + const paywall = await runWithProgressRetries(wex, () => + wex.http.fetch(url, { + method: "GET", + redirect: "follow", + cancellationToken: wex.cancellationToken, + }), + ); + if (paywall.status !== 402) { + throw malformedPaivanaResponse( + paywall.requestUrl, + paywall.requestMethod, + paywall.status, + "the URL did not lead to a Paivana 402 paywall", + ); + } + const advertisedUri = paywall.headers.get("Paivana"); + if (!advertisedUri) { + throw malformedPaivanaResponse( + paywall.requestUrl, + paywall.requestMethod, + paywall.status, + "the Paivana paywall did not advertise a payment template", + ); + } + const parsed = parsePaivanaTemplateUri(advertisedUri); + if (!parsed) { + throw malformedPaivanaResponse( + paywall.requestUrl, + paywall.requestMethod, + paywall.status, + "the Paivana header did not contain a valid pay-template URI", + ); + } + + const merchantApi = new TalerMerchantInstanceHttpClient( + parsed.merchantBaseUrl, + wex.http, + undefined, + wex.cancellationToken, + ); + const templateInfo = await runWithProgressRetries(wex, async () => + succeedOrThrow(await merchantApi.useTemplateGetInfo(parsed.templateId)), + ); + requirePaivanaTemplate(templateInfo); + + const expiration = getPaivanaExpiration( + templateInfo.template_contract.max_pickup_duration, + ); + const nonceBytes = getRandomBytes(16); + const redemption: PaivanaRedemption = { + url, + nonce: encodeCrock(nonceBytes), + expiration, + }; + const payTemplate: TalerPayTemplateUri = { + ...parsed, + sessionId: makePaivanaSessionId(expiration, nonceBytes, url), + fulfillmentUrl: url, + }; + return { + request, + redemption, + talerPayTemplateUri: TalerUris.stringify(payTemplate), + templateInfo, + }; +} + +export async function preparePayForPaivana( + wex: WalletExecutionContext, + req: PreparePayForPaivanaRequest, +): Promise<PreparePayForPaivanaResult> { + return runWithMaybeProgressContext( + wex, + "preparePayForPaivana", + req.progressToken, + async () => { + const prepared = await preparePaivanaTemplate(wex, req); + const instantiated = await instantiateTemplateRaw( + wex, + { talerPayTemplateUri: prepared.talerPayTemplateUri }, + prepared.templateInfo, + ); + const proposalRes = await createOrReusePurchase( + wex, + instantiated.merchantBaseUrl, + instantiated.orderId, + instantiated.sessionId, + instantiated.claimToken, + undefined, + prepared.talerPayTemplateUri, + ); + return { + transactionId: proposalRes.transactionId, + paivana: prepared.redemption, + }; + }, + ); +} + +/** Reduce Paivana's Set-Cookie response to a Cookie header name/value pair. */ +export function extractPaivanaCookie(setCookie: string | null): string { + if (setCookie === null) { + throw Error("missing Paivana Set-Cookie header"); + } + const firstPart = setCookie.split(";", 1)[0]; + if (!/^Paivana-Cookie=[^\s,;]+$/.test(firstPart)) { + throw Error("invalid Paivana Set-Cookie header"); + } + return firstPart; +} + +export function validatePaivanaRedemption(redemption: PaivanaRedemption): { + url: string; + nonceBytes: Uint8Array; + sessionId: string; +} { + const url = canonicalizePaivanaUrl(redemption.url); + let nonceBytes: Uint8Array; + try { + nonceBytes = decodeCrock(redemption.nonce); + } catch { + nonceBytes = new Uint8Array(); + } + if ( + url !== redemption.url || + redemption.expiration.t_s === "never" || + nonceBytes.byteLength !== 16 || + encodeCrock(nonceBytes) !== redemption.nonce + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "paivana" }, + "invalid Paivana redemption metadata", + ); + } + return { + url, + nonceBytes, + sessionId: makePaivanaSessionId(redemption.expiration, nonceBytes, url), + }; +} + +export async function getPaivanaCookie( + wex: WalletExecutionContext, + req: GetPaivanaCookieRequest, +): Promise<GetPaivanaCookieResult> { + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (parsedTx?.tag !== TransactionType.Payment) { + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.Payment, + ); + } + const { url, sessionId } = validatePaivanaRedemption(req.paivana); + + const purchaseInfo = await wex.runWalletDbTx(async (tx) => { + let p = await tx.getPurchase(parsedTx.proposalId); + if (!p) { + return undefined; + } + const requestedSessionId = p.downloadSessionId; + if (p.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) { + if (!p.repurchaseProposalId) { + return { purchase: p, requestedSessionId }; + } + p = await tx.getPurchase(p.repurchaseProposalId); + } + return p ? { purchase: p, requestedSessionId } : undefined; + }); + if (!purchaseInfo) { + throw makeTransactionNotFoundError(req.transactionId); + } + const { purchase, requestedSessionId } = purchaseInfo; + if (requestedSessionId !== sessionId) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "paivana" }, + "the Paivana redemption metadata does not match the transaction", + ); + } + if (purchase.purchaseStatus !== PurchaseStatus.Done) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { txState: computePayMerchantTransactionState(purchase) }, + "the Paivana cookie can only be redeemed after payment succeeds", + ); + } + if (purchase.download?.fulfillmentUrl !== url) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "paivana.url" }, + "the Paivana URL does not match the paid contract", + ); + } + + const endpoint = new URL("/.well-known/paivana", url).href; + const response = await wex.http.fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + order_id: purchase.orderId, + nonce: req.paivana.nonce, + expiration: req.paivana.expiration, + website: url, + }, + redirect: "manual", + cancellationToken: wex.cancellationToken, + }); + if (response.status !== 303) { + if (response.status >= 400) { + throw TalerError.fromUncheckedDetail( + await readTalerErrorResponse(response), + ); + } + throw malformedPaivanaResponse( + response.requestUrl, + response.requestMethod, + response.status, + "Paivana redemption did not return the required 303 response", + ); + } + const location = response.headers.get("Location"); + let destination: string; + try { + destination = new URL(location ?? "", endpoint).href; + } catch { + destination = ""; + } + if (!location || destination !== url) { + throw malformedPaivanaResponse( + response.requestUrl, + response.requestMethod, + response.status, + "Paivana redemption redirected to an unexpected destination", + ); + } + let cookie: string; + try { + cookie = extractPaivanaCookie(response.headers.get("Set-Cookie")); + } catch { + throw malformedPaivanaResponse( + response.requestUrl, + response.requestMethod, + response.status, + "Paivana redemption returned an invalid access cookie", + ); + } + return { cookie }; +} diff --git a/packages/taler-wallet-core/src/pay-template.ts b/packages/taler-wallet-core/src/pay-template.ts @@ -37,6 +37,7 @@ import { TemplateParams, TemplateType, UsingTemplateDetailsRequest, + WalletTemplateDetailsResponse, } from "@gnu-taler/taler-util"; import { runWithMaybeProgressContext, @@ -242,6 +243,7 @@ async function internalCheckPayForTemplate( export async function instantiateTemplateRaw( wex: WalletExecutionContext, req: PreparePayTemplateRequest, + prefetchedTemplateInfo?: WalletTemplateDetailsResponse, ): Promise<{ merchantBaseUrl: string; orderId: string; @@ -275,9 +277,13 @@ export async function instantiateTemplateRaw( logger.trace(`parsed URI: ${j2s(parsedUri)}`); // Retried under a progress context. - const templateInfo = await runWithProgressRetries(wex, async () => - succeedOrThrow(await merchantApi.useTemplateGetInfo(parsedUri.templateId)), - ); + const templateInfo = + prefetchedTemplateInfo ?? + (await runWithProgressRetries(wex, async () => + succeedOrThrow( + await merchantApi.useTemplateGetInfo(parsedUri.templateId), + ), + )); let templateDetails: UsingTemplateDetailsRequest; switch (templateInfo.template_contract.template_type) { diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -203,6 +203,7 @@ import { codecForGetExchangeTosRequest, codecForGetMaxDepositAmountRequest, codecForGetMaxPeerPushDebitAmountRequest, + codecForGetPaivanaCookieRequest, codecForGetPerformanceStatsRequest, codecForGetQrCodesForPaytoRequest, codecForGetTransactionsV2Request, @@ -225,6 +226,7 @@ import { codecForMailboxBaseUrl, codecForMailboxConfiguration, codecForPrepareBankIntegratedWithdrawalRequest, + codecForPreparePayForPaivanaRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, codecForPreparePeerPullPaymentRequest, @@ -354,6 +356,7 @@ import { startQueryRefund, startRefundQueryForUri, } from "./pay-merchant.js"; +import { getPaivanaCookie, preparePayForPaivana } from "./pay-paivana.js"; import { checkPeerPullCredit, initiatePeerPullPayment, @@ -2725,6 +2728,14 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForPreparePayTemplateRequest(), handler: preparePayForTemplateV2, }, + [WalletApiOperation.PreparePayForPaivana]: { + codec: codecForPreparePayForPaivanaRequest(), + handler: preparePayForPaivana, + }, + [WalletApiOperation.GetPaivanaCookie]: { + codec: codecForGetPaivanaCookieRequest(), + handler: getPaivanaCookie, + }, [WalletApiOperation.GetQrCodesForPayto]: { codec: codecForGetQrCodesForPaytoRequest(), handler: handleGetQrCodesForPayto, diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -121,6 +121,8 @@ import { GetMaxPeerPushDebitAmountResponse, GetPerformanceStatsRequest, GetPerformanceStatsResponse, + GetPaivanaCookieRequest, + GetPaivanaCookieResult, GetQrCodesForPaytoRequest, GetQrCodesForPaytoResponse, GetTransactionsV2Request, @@ -158,6 +160,8 @@ import { MailboxMessagesResponse, PrepareBankIntegratedWithdrawalRequest, PrepareBankIntegratedWithdrawalResponse, + PreparePayForPaivanaRequest, + PreparePayForPaivanaResult, PreparePayRequest, PreparePayTemplateRequest, PreparePayV2Result, @@ -306,6 +310,8 @@ export enum WalletApiOperation { GetChoicesForPayment = "getChoicesForPayment", PreparePayForUriV2 = "preparePayForUriV2", PreparePayForTemplateV2 = "preparePayForTemplateV2", + PreparePayForPaivana = "preparePayForPaivana", + GetPaivanaCookie = "getPaivanaCookie", SharePayment = "sharePayment", CheckPayForTemplate = "checkPayForTemplate", StartRefundQueryForUri = "startRefundQueryForUri", @@ -879,6 +885,20 @@ export type PreparePayForTemplateV2Op = { response: PreparePayV2Result; }; +/** Prepare a payment for an HTTP(S) resource protected by Paivana. */ +export type PreparePayForPaivanaOp = { + op: WalletApiOperation.PreparePayForPaivana; + request: PreparePayForPaivanaRequest; + response: PreparePayForPaivanaResult; +}; + +/** Redeem a successfully paid Paivana transaction for an access cookie. */ +export type GetPaivanaCookieOp = { + op: WalletApiOperation.GetPaivanaCookie; + request: GetPaivanaCookieRequest; + response: GetPaivanaCookieResult; +}; + /** * Get a list of contract v1 choices for a given payment tx * in dialog(confirm) state, as well as additional information @@ -1778,6 +1798,32 @@ export const walletApiExpectedErrors = { TalerErrorCode.WALLET_TALER_URI_MALFORMED, TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, ], + [WalletApiOperation.PreparePayForPaivana]: [ + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + TalerErrorCode.WALLET_NETWORK_ERROR, + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT, + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + ], + [WalletApiOperation.GetPaivanaCookie]: [ + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + TalerErrorCode.WALLET_NETWORK_ERROR, + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT, + TalerErrorCode.PAIVANA_PAYMENT_MISSING, + TalerErrorCode.PAIVANA_BACKEND_REFUSED, + TalerErrorCode.PAIVANA_ORDER_UNKNOWN, + TalerErrorCode.PAIVANA_BACKEND_ERROR, + TalerErrorCode.PAIVANA_GET_ORDER_FAILED, + TalerErrorCode.PAIVANA_WRONG_ORDER, + TalerErrorCode.PAIVANA_TOO_LATE, + TalerErrorCode.PAIVANA_INVALID_TARGET, + ], [WalletApiOperation.CheckPayForTemplate]: [ TalerErrorCode.WALLET_TALER_URI_MALFORMED, TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, @@ -2002,6 +2048,8 @@ export type WalletOperations = { [WalletApiOperation.GetVersion]: GetVersionOp; [WalletApiOperation.PreparePayForUriV2]: PreparePayForUriV2Op; [WalletApiOperation.PreparePayForTemplateV2]: PreparePayForTemplateV2Op; + [WalletApiOperation.PreparePayForPaivana]: PreparePayForPaivanaOp; + [WalletApiOperation.GetPaivanaCookie]: GetPaivanaCookieOp; [WalletApiOperation.SharePayment]: SharePaymentOp; [WalletApiOperation.CheckPayForTemplate]: CheckPayForTemplateOp; [WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;