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