taler-typescript-core

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

commit 8472edd564a1c09ecf4516e8bd30c7a0a11ba88e
parent e2ccfe83e8b4da64baff552950868b0d5a631ac7
Author: Florian Dold <dold@taler.net>
Date:   Fri,  4 Sep 2026 01:00:30 +0200

taler-util: validate protocol responses and Taler URIs

Add codecs for reserve history, batch deposits, merchant refunds, and
common response types. Reject malformed Taler URIs and report HTTP
transport failures consistently.

Diffstat:
Mpackages/taler-util/src/ReserveStatus.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-util/src/http-client/donau-client.test.ts | 8++++++++
Mpackages/taler-util/src/http-client/donau-client.ts | 2++
Apackages/taler-util/src/http-client/exchange-batch-deposit.test.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/exchange-client.ts | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Apackages/taler-util/src/http-client/exchange-reserve-history.test.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/http-client/merchant-refund.test.ts | 31+++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 2++
Mpackages/taler-util/src/http-common.test.ts | 18++++++++++++++++++
Mpackages/taler-util/src/http-common.ts | 8++------
Mpackages/taler-util/src/http-impl.node.test.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-impl.node.ts | 6++++++
Apackages/taler-util/src/http-impl.qtart-common.test.ts | 33+++++++++++++++++++++++++++++++++
Apackages/taler-util/src/http-impl.qtart-common.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-impl.qtart.ts | 17++++++++---------
Mpackages/taler-util/src/qtart.ts | 2++
Mpackages/taler-util/src/taleruri.test.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-util/src/taleruri.ts | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-util/src/taleruris.test.ts | 51++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-util/src/types-taler-common.test.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-common.ts | 262++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/types-taler-exchange.ts | 26+++++++++++++++++++++++++-
Mpackages/taler-util/src/types-taler-wallet.ts | 18++++++++++++++----
Mpackages/taler-wallet-core/src/requests.ts | 3+++
Mpackages/wallet-webui/src/testing/demo-wallet.ts | 2+-
25 files changed, 1066 insertions(+), 90 deletions(-)

diff --git a/packages/taler-util/src/ReserveStatus.ts b/packages/taler-util/src/ReserveStatus.ts @@ -25,11 +25,22 @@ import { codecForAmountString } from "./amounts.js"; import { Codec, buildCodecForObject, + buildCodecForUnion, + codecForBoolean, + codecForConstString, + codecForList, + codecForNumber, codecForString, codecOptional, } from "./codec.js"; -import { TalerProtocolTimestamp } from "./time.js"; -import { AmountString, EddsaSignatureString } from "./types-taler-common.js"; +import { TalerProtocolTimestamp, codecForTimestamp } from "./time.js"; +import { + AmountString, + EddsaPublicKeyString, + EddsaSignatureString, + HashCodeString, + Integer, +} from "./types-taler-common.js"; /** * Status of a reserve. @@ -58,6 +69,96 @@ export const codecForReserveStatus = (): Codec<ReserveStatus> => .property("last_origin", codecOptional(codecForString())) .build("ReserveStatus"); +export type ReserveHistoryNonMergeType = + | "SETUP" + | "WITHDRAW" + | "CREDIT" + | "CLOSING" + | "RECOUP" + | "HISTORY" + | "OPEN" + | "CLOSE"; + +export interface ReserveHistoryNonMergeEntry { + type: ReserveHistoryNonMergeType; + history_offset: Integer; +} + +export interface ReserveHistoryMergeEntry { + type: "MERGE"; + history_offset: Integer; + h_contract_terms: HashCodeString; + merge_pub: EddsaPublicKeyString; + min_age: Integer; + flags: Integer; + purse_pub: EddsaPublicKeyString; + reserve_sig: EddsaSignatureString; + merge_timestamp: TalerProtocolTimestamp; + purse_expiration: TalerProtocolTimestamp; + purse_fee: AmountString; + amount: AmountString; + merged: boolean; +} + +export type ReserveHistoryEntry = + | ReserveHistoryNonMergeEntry + | ReserveHistoryMergeEntry; + +export interface ReserveHistory { + balance: AmountString; + maximum_age_group?: Integer; + history: ReserveHistoryEntry[]; +} + +const reserveHistoryNonMergeEntryCodec = ( + type: ReserveHistoryNonMergeType, +): Codec<ReserveHistoryNonMergeEntry> => + buildCodecForObject<ReserveHistoryNonMergeEntry>() + .allowExtra() + .property("type", codecForConstString(type)) + .property("history_offset", codecForNumber()) + .build(`ReserveHistory${type}Entry`); + +export const codecForReserveHistoryEntry = (): Codec<ReserveHistoryEntry> => { + return buildCodecForUnion<ReserveHistoryEntry>() + .discriminateOn("type") + .alternative("SETUP", reserveHistoryNonMergeEntryCodec("SETUP")) + .alternative("WITHDRAW", reserveHistoryNonMergeEntryCodec("WITHDRAW")) + .alternative("CREDIT", reserveHistoryNonMergeEntryCodec("CREDIT")) + .alternative("CLOSING", reserveHistoryNonMergeEntryCodec("CLOSING")) + .alternative("RECOUP", reserveHistoryNonMergeEntryCodec("RECOUP")) + .alternative("HISTORY", reserveHistoryNonMergeEntryCodec("HISTORY")) + .alternative("OPEN", reserveHistoryNonMergeEntryCodec("OPEN")) + .alternative("CLOSE", reserveHistoryNonMergeEntryCodec("CLOSE")) + .alternative( + "MERGE", + buildCodecForObject<ReserveHistoryMergeEntry>() + .allowExtra() + .property("type", codecForConstString("MERGE")) + .property("history_offset", codecForNumber()) + .property("h_contract_terms", codecForString()) + .property("merge_pub", codecForString()) + .property("min_age", codecForNumber()) + .property("flags", codecForNumber()) + .property("purse_pub", codecForString()) + .property("reserve_sig", codecForString()) + .property("merge_timestamp", codecForTimestamp) + .property("purse_expiration", codecForTimestamp) + .property("purse_fee", codecForAmountString()) + .property("amount", codecForAmountString()) + .property("merged", codecForBoolean()) + .build("ReserveHistoryMergeEntry"), + ) + .build("ReserveHistoryEntry"); +}; + +export const codecForReserveHistory = (): Codec<ReserveHistory> => + buildCodecForObject<ReserveHistory>() + .property("balance", codecForAmountString()) + .property("maximum_age_group", codecOptional(codecForNumber())) + .property("history", codecForList(codecForReserveHistoryEntry())) + .build("ReserveHistory"); + export interface ReserveCloseRequest { reserve_sig: EddsaSignatureString; request_timestamp: TalerProtocolTimestamp; diff --git a/packages/taler-util/src/http-client/donau-client.test.ts b/packages/taler-util/src/http-client/donau-client.test.ts @@ -57,3 +57,11 @@ test("prepareIssueReceipt treats 201 Created as success", async (t) => { `expected success, got case ${(res as any).case}`, ); }); + +test("getDonationStatement models 204 when no statement exists", async () => { + const client = new DonauHttpClient("https://donau.example.com/service/", { + httpClient: fixedStatusLib(204, undefined), + }); + const res = await client.getDonationStatement(2026, "donor-hash"); + assert.strictEqual(res.case, 204); +}); diff --git a/packages/taler-util/src/http-client/donau-client.ts b/packages/taler-util/src/http-client/donau-client.ts @@ -243,6 +243,8 @@ export class DonauHttpClient { resp, codecForDonauDonationStatementResponse(), ); + case HttpStatusCode.NoContent: + return opKnownFailure(resp, resp.status); case HttpStatusCode.Forbidden: return opKnownFailure(resp, resp.status); case HttpStatusCode.NotFound: diff --git a/packages/taler-util/src/http-client/exchange-batch-deposit.test.ts b/packages/taler-util/src/http-client/exchange-batch-deposit.test.ts @@ -0,0 +1,36 @@ +/* + 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 assert from "node:assert/strict"; +import test from "node:test"; +import { FakeHttpLib } from "../http-fake.js"; +import { HttpStatusCode } from "../http-status-codes.js"; +import { TalerExchangeHttpClient } from "./exchange-client.js"; + +test("batch deposit exposes every documented permanent HTTP status", async () => { + for (const status of [ + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.Gone, + HttpStatusCode.PreconditionFailed, + HttpStatusCode.PayloadTooLarge, + ]) { + const http = new FakeHttpLib().on("POST", "/batch-deposit", { + status, + body: { code: 1000, hint: `status ${status}` }, + }); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: http, + }); + const result = await client.batchDeposit({ body: {} as never }); + assert.strictEqual(result.case, status); + } +}); diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -14,7 +14,6 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { codecForAny } from "../codec.js"; import { HttpRequestLibrary, HttpRequestOptions, @@ -134,8 +133,10 @@ import { import { ReserveCloseRequest, ReserveCloseResponse, + ReserveHistory, ReserveStatus, codecForReserveCloseResponse, + codecForReserveHistory, codecForReserveStatus, } from "../ReserveStatus.js"; import { @@ -206,7 +207,10 @@ function addYesNoFilter( * Client library for the GNU Taler exchange service. */ export class TalerExchangeHttpClient { - public static readonly SUPPORTED_EXCHANGE_PROTOCOL_VERSION = "34:0:9"; + // Protocol v25 requires a legacy withdrawal commitment that this wallet did + // not persist. Do not advertise compatibility with a version whose recoup + // path cannot be implemented safely. + public static readonly SUPPORTED_EXCHANGE_PROTOCOL_VERSION = "34:0:8"; private httpLib: HttpRequestLibrary; private cacheEvictor: CacheEvictor<TalerExchangeCacheEviction>; private preventCompression: boolean; @@ -1884,15 +1888,34 @@ export class TalerExchangeHttpClient { async getReserveHistory( reservePub: string, signature: string, - ): Promise<OperationOk<any>> { - const resp = await this.fetch(`reserves/${reservePub}/history`, { + startOffset: number = 0, + ): Promise< + | OperationOk<ReserveHistory> + | OperationAlternative<HttpStatusCode.NoContent, void> + | OperationAlternative<HttpStatusCode.NotModified, void> + | OperationFail<HttpStatusCode.Forbidden | HttpStatusCode.NotFound> + > { + const url = new URL( + `reserves/${pathSegment(reservePub)}/history`, + this.baseUrl, + ); + if (startOffset !== 0) { + url.searchParams.set("start", String(startOffset)); + } + const resp = await this.fetch(url, { headers: { "Taler-Reserve-History-Signature": signature, }, }); switch (resp.status) { case HttpStatusCode.Ok: - return opSuccessFromHttp(resp, codecForAny()); + return opSuccessFromHttp(resp, codecForReserveHistory()); + case HttpStatusCode.NoContent: + case HttpStatusCode.NotModified: + return opKnownFailureWithBody(resp, resp.status, undefined); + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); } @@ -1904,7 +1927,16 @@ export class TalerExchangeHttpClient { async recoupCoin( coinPub: string, body: RecoupRequest, - ): Promise<OperationOk<RecoupConfirmation>> { + ): Promise< + | OperationOk<RecoupConfirmation> + | OperationFail< + | HttpStatusCode.BadRequest + | HttpStatusCode.Forbidden + | HttpStatusCode.NotFound + | HttpStatusCode.Conflict + | HttpStatusCode.Gone + > + > { const resp = await this.fetch(`coins/${coinPub}/recoup`, { method: "POST", body, @@ -1912,6 +1944,12 @@ export class TalerExchangeHttpClient { switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForRecoupConfirmation()); + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + case HttpStatusCode.Gone: + return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); } @@ -1923,7 +1961,16 @@ export class TalerExchangeHttpClient { async recoupRefreshCoin( coinPub: string, body: RecoupRefreshRequest, - ): Promise<OperationOk<RecoupConfirmation>> { + ): Promise< + | OperationOk<RecoupConfirmation> + | OperationFail< + | HttpStatusCode.BadRequest + | HttpStatusCode.Forbidden + | HttpStatusCode.NotFound + | HttpStatusCode.Conflict + | HttpStatusCode.Gone + > + > { const resp = await this.fetch(`coins/${coinPub}/recoup-refresh`, { method: "POST", body, @@ -1931,6 +1978,12 @@ export class TalerExchangeHttpClient { switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForRecoupConfirmation()); + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + case HttpStatusCode.Gone: + return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); } @@ -1985,6 +2038,15 @@ export class TalerExchangeHttpClient { body: ExchangeBatchDepositRequest; }): Promise< | OperationOk<BatchDepositSuccess> + | OperationFail< + | HttpStatusCode.BadRequest + | HttpStatusCode.Forbidden + | HttpStatusCode.NotFound + | HttpStatusCode.Conflict + | HttpStatusCode.Gone + | HttpStatusCode.PreconditionFailed + | HttpStatusCode.PayloadTooLarge + > | OperationAlternative< HttpStatusCode.UnavailableForLegalReasons, LegitimizationNeededResponse @@ -1998,6 +2060,14 @@ export class TalerExchangeHttpClient { case HttpStatusCode.Ok: case HttpStatusCode.Accepted: return opSuccessFromHttp(resp, codecForBatchDepositSuccess()); + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + case HttpStatusCode.Gone: + case HttpStatusCode.PreconditionFailed: + case HttpStatusCode.PayloadTooLarge: + return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.UnavailableForLegalReasons: return opKnownAlternativeHttpFailure( resp, diff --git a/packages/taler-util/src/http-client/exchange-reserve-history.test.ts b/packages/taler-util/src/http-client/exchange-reserve-history.test.ts @@ -0,0 +1,75 @@ +/* + 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 assert from "node:assert/strict"; +import test from "node:test"; +import { FakeHttpLib, noContent, ok } from "../http-fake.js"; +import { HttpStatusCode } from "../http-status-codes.js"; +import { TalerExchangeHttpClient } from "./exchange-client.js"; + +test("reserve history binds the signature to the requested start offset", async () => { + const http = new FakeHttpLib().on( + "GET", + "/reserves/reserve-pub/history", + ok({ + balance: "TESTKUDOS:2", + history: [{ type: "CREDIT", history_offset: 4 }], + }), + ); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: http, + }); + + const result = await client.getReserveHistory("reserve-pub", "sig-3", 3); + + assert.strictEqual(result.case, "ok"); + assert.strictEqual( + new URL(http.lastRequest!.url).searchParams.get("start"), + "3", + ); + assert.strictEqual( + http.lastRequest!.headers?.["Taler-Reserve-History-Signature"], + "sig-3", + ); +}); + +test("reserve history exposes pagination terminators", async () => { + for (const status of [HttpStatusCode.NoContent, HttpStatusCode.NotModified]) { + const http = new FakeHttpLib().otherwise( + status === HttpStatusCode.NoContent + ? noContent() + : { status: HttpStatusCode.NotModified }, + ); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: http, + }); + const result = await client.getReserveHistory( + "reserve-pub", + "signature", + 9, + ); + assert.strictEqual(result.case, status); + } +}); + +test("reserve history rejects incomplete merge entries", async () => { + const http = new FakeHttpLib().on( + "GET", + "/reserves/reserve-pub/history", + ok({ + balance: "TESTKUDOS:2", + history: [{ type: "MERGE", history_offset: 1, merged: true }], + }), + ); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: http, + }); + + await assert.rejects(client.getReserveHistory("reserve-pub", "signature")); +}); diff --git a/packages/taler-util/src/http-client/merchant-refund.test.ts b/packages/taler-util/src/http-client/merchant-refund.test.ts @@ -0,0 +1,31 @@ +/* + 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 assert from "node:assert/strict"; +import test from "node:test"; +import { FakeHttpLib } from "../http-fake.js"; +import { HttpStatusCode } from "../http-status-codes.js"; +import { TalerMerchantInstanceHttpClient } from "./merchant.js"; + +test("merchant refund models an expired refund as HTTP 410", async () => { + const http = new FakeHttpLib().on("POST", "/orders/order/refund", { + status: HttpStatusCode.Gone, + body: { code: 2000, hint: "wire deadline passed" }, + }); + const client = new TalerMerchantInstanceHttpClient( + "https://merchant.example/", + http, + ); + + const result = await client.obtainRefund("order", { + h_contract: "contract-hash", + }); + + assert.strictEqual(result.case, HttpStatusCode.Gone); +}); diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -777,6 +777,8 @@ export class TalerMerchantInstanceHttpClient { return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Gone: + return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.UnavailableForLegalReasons: return opKnownAlternativeHttpFailure( resp, diff --git a/packages/taler-util/src/http-common.test.ts b/packages/taler-util/src/http-common.test.ts @@ -23,6 +23,7 @@ import { HttpLib, HttpRawRequestOptions, HttpResponse, + RequestCancelledError, readTalerErrorResponse, readUnexpectedResponseDetails, } from "./http-common.js"; @@ -139,3 +140,20 @@ test("DELETE preserves a caller-supplied content type", async () => { assert.strictEqual(requests[0].headers["Content-Type"], "application/custom"); assert.strictEqual(new TextDecoder().decode(requests[0].body), "payload"); }); + +test("HTTP cancellation maps to the public cancellation error", async () => { + const http = new HttpLib( + { + async fetch() { + throw new RequestCancelledError(); + }, + }, + { enableThrottling: false }, + ); + await assert.rejects( + () => http.fetch("https://example.com/cancelled"), + (e: unknown) => + e instanceof TalerError && + e.errorDetail.code === TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED, + ); +}); diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts @@ -807,12 +807,8 @@ export class HttpLib implements HttpRequestLibrary { if (cause instanceof RequestCancelledError) { logger.trace(`request ${rid} cancelled`); return TalerError.fromDetail( - TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, - { - requestUrl, - requestMethod, - httpStatusCode: 0, - }, + TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED, + {}, `Request cancelled`, ); } diff --git a/packages/taler-util/src/http-impl.node.test.ts b/packages/taler-util/src/http-impl.node.test.ts @@ -74,3 +74,39 @@ test("node HTTP requests with a buffered body use Content-Length", async (t) => ); assert.strictEqual(request.headers["content-encoding"], "deflate"); }); + +test("node HTTP implements follow, manual and error redirect modes", async (t) => { + const server = createServer((req, res) => { + if (req.url === "/redirect") { + res.writeHead(302, { location: "/destination" }); + res.end(); + return; + } + res.writeHead(200, { "content-type": "text/plain" }); + res.end("destination"); + }); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + t.after( + () => + new Promise<void>((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + ); + + const port = (server.address() as AddressInfo).port; + const url = `http://127.0.0.1:${port}/redirect`; + const http = createPlatformHttpLib({ enableThrottling: false }); + + const followed = await http.fetch(url, { redirect: "follow" }); + assert.strictEqual(followed.status, 200); + assert.strictEqual(await followed.text(), "destination"); + + const manual = await http.fetch(url, { redirect: "manual" }); + assert.strictEqual(manual.status, 302); + assert.strictEqual(manual.headers.get("location"), "/destination"); + + await assert.rejects(() => http.fetch(url, { redirect: "error" })); +}); diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts @@ -120,6 +120,12 @@ export const rawLib: HttpRawLib = { headers, timeout: opt.timeoutMs, followRedirects: opt.redirect !== "manual", + beforeRedirect: + opt.redirect === "error" + ? () => { + throw new Error("redirect encountered with redirect mode error"); + } + : undefined, }; const chunks: Uint8Array[] = []; diff --git a/packages/taler-util/src/http-impl.qtart-common.test.ts b/packages/taler-util/src/http-impl.qtart-common.test.ts @@ -0,0 +1,33 @@ +/* + 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 assert from "node:assert/strict"; +import test from "node:test"; +import { + normalizeQtartHeaderValue, + qtartRedirectMode, +} from "./http-impl.qtart-common.js"; + +test("qtart maps every shared redirect mode to the native contract", () => { + assert.strictEqual(qtartRedirectMode(undefined), 0); + assert.strictEqual(qtartRedirectMode("follow"), 0); + assert.strictEqual(qtartRedirectMode("manual"), 1); + assert.strictEqual(qtartRedirectMode("error"), 2); +}); + +test("qtart removes exactly the Android content-type brackets", () => { + assert.strictEqual( + normalizeQtartHeaderValue("content-type", "[application/json]"), + "application/json", + ); + assert.strictEqual( + normalizeQtartHeaderValue("content-type", "application/json"), + "application/json", + ); +}); diff --git a/packages/taler-util/src/http-impl.qtart-common.ts b/packages/taler-util/src/http-impl.qtart-common.ts @@ -0,0 +1,36 @@ +/* + 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 function qtartRedirectMode( + mode: "follow" | "error" | "manual" | undefined, +): 0 | 1 | 2 { + switch (mode) { + case undefined: + case "follow": + return 0; + case "manual": + return 1; + case "error": + return 2; + } +} + +export function normalizeQtartHeaderValue( + headerName: string, + headerValue: string, +): string { + if ( + headerName.toLowerCase() === "content-type" && + headerValue.startsWith("[") && + headerValue.endsWith("]") + ) { + return headerValue.substring(1, headerValue.length - 1); + } + return headerValue; +} diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts @@ -29,6 +29,10 @@ import { } from "./http-common.js"; import { QjsHttpResp, qjsOs } from "./qtart.js"; import { Duration } from "./time.js"; +import { + normalizeQtartHeaderValue, + qtartRedirectMode, +} from "./http-impl.qtart-common.js"; const logger = new Logger("http-impl.qtart.ts"); @@ -57,6 +61,7 @@ export const rawLib: HttpRawLib = { method: opt.method, data: opt.body, headers: headersList, + redirect: qtartRedirectMode(opt.redirect), }); // awaitNativeRequest applies the timeout and cancellation token, cancels @@ -88,15 +93,9 @@ export const rawLib: HttpRawLib = { } const headerName = headerStr.slice(0, splitPos).trim().toLowerCase(); let headerValue = headerStr.slice(splitPos + 1).trim(); - // FIXME: This is a hotfix for the broken native networking implementation on Android - // that sends the content type header value in square brackets - if ( - headerName === "content-type" && - headerValue.startsWith("[") && - headerValue.endsWith("]") - ) { - headerValue = headerValue.substring(1, headerValue.length - 2); - } + // Work around Android native networking returning content types in + // square brackets. + headerValue = normalizeQtartHeaderValue(headerName, headerValue); headers.set(headerName, headerValue); } } diff --git a/packages/taler-util/src/qtart.ts b/packages/taler-util/src/qtart.ts @@ -14,6 +14,8 @@ export interface QjsHttpOptions { debug?: boolean; data?: ArrayBuffer | ArrayBufferView; headers?: string[]; + /** 0 = follow, 1 = manual, 2 = reject redirects. */ + redirect?: 0 | 1 | 2; } export interface ErrReceiver { diff --git a/packages/taler-util/src/taleruri.test.ts b/packages/taler-util/src/taleruri.test.ts @@ -24,7 +24,14 @@ import { TalerUriAction, TalerUris, } from "./taleruri.js"; -import { AmountString } from "./types-taler-common.js"; +import { + AmountString, + EddsaPrivateKeyString, + EddsaPublicKeyString, +} from "./types-taler-common.js"; + +const VALID_PRIVATE_KEY = "0".repeat(52) as EddsaPrivateKeyString; +const VALID_MAILBOX_KEY = "1".repeat(52) as EddsaPublicKeyString; { /** @@ -293,7 +300,7 @@ import { AmountString } from "./types-taler-common.js"; */ test("taler peer to peer push URI", (t) => { - const url1 = "taler://pay-push/exch.example.com/foo"; + const url1 = `taler://pay-push/exch.example.com/${VALID_PRIVATE_KEY}`; const r1 = Result.orUndefined( TalerUris.parseRestricted(url1, TalerUriAction.PayPush), ); @@ -305,11 +312,11 @@ import { AmountString } from "./types-taler-common.js"; r1.exchangeBaseUrl, "https://exch.example.com/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler peer to peer push URI (path)", (t) => { - const url1 = "taler://pay-push/exch.example.com:123/bla/foo"; + const url1 = `taler://pay-push/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.orUndefined( TalerUris.parseRestricted(url1, TalerUriAction.PayPush), ); @@ -321,11 +328,11 @@ import { AmountString } from "./types-taler-common.js"; r1.exchangeBaseUrl, "https://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler peer to peer push URI (http)", (t) => { - const url1 = "taler+http://pay-push/exch.example.com:123/bla/foo"; + const url1 = `taler+http://pay-push/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.orUndefined( TalerUris.parseRestricted(url1, TalerUriAction.PayPush), ); @@ -337,16 +344,19 @@ import { AmountString } from "./types-taler-common.js"; r1.exchangeBaseUrl, "http://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler peer to peer push URI (stringify)", (t) => { const url = TalerUris.stringify({ type: TalerUriAction.PayPush, exchangeBaseUrl: "https://foo.example.com/bla/" as HostPortPath, - contractPriv: "123", + contractPriv: VALID_PRIVATE_KEY, }); - assert.deepStrictEqual(url, "taler://pay-push/foo.example.com/bla/123"); + assert.deepStrictEqual( + url, + `taler://pay-push/foo.example.com/bla/${VALID_PRIVATE_KEY}`, + ); }); /** @@ -354,7 +364,7 @@ import { AmountString } from "./types-taler-common.js"; */ test("taler peer to peer pull URI", (t) => { - const url1 = "taler://pay-pull/exch.example.com/foo"; + const url1 = `taler://pay-pull/exch.example.com/${VALID_PRIVATE_KEY}`; const r1 = Result.orUndefined( TalerUris.parseRestricted(url1, TalerUriAction.PayPull), ); @@ -366,11 +376,11 @@ import { AmountString } from "./types-taler-common.js"; r1.exchangeBaseUrl, "https://exch.example.com/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler peer to peer pull URI (path)", (t) => { - const url1 = "taler://pay-pull/exch.example.com:123/bla/foo"; + const url1 = `taler://pay-pull/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.orUndefined( TalerUris.parseRestricted(url1, TalerUriAction.PayPull), ); @@ -382,11 +392,11 @@ import { AmountString } from "./types-taler-common.js"; r1.exchangeBaseUrl, "https://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler peer to peer pull URI (http)", (t) => { - const url1 = "taler+http://pay-pull/exch.example.com:123/bla/foo"; + const url1 = `taler+http://pay-pull/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.orUndefined( TalerUris.parseRestricted(url1, TalerUriAction.PayPull), ); @@ -398,16 +408,45 @@ import { AmountString } from "./types-taler-common.js"; r1.exchangeBaseUrl, "http://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler peer to peer pull URI (stringify)", (t) => { const url = TalerUris.stringify({ type: TalerUriAction.PayPull, exchangeBaseUrl: "https://foo.example.com/bla/" as HostPortPath, - contractPriv: "123", + contractPriv: VALID_PRIVATE_KEY, }); - assert.deepStrictEqual(url, "taler://pay-pull/foo.example.com/bla/123"); + assert.deepStrictEqual( + url, + `taler://pay-pull/foo.example.com/bla/${VALID_PRIVATE_KEY}`, + ); + }); + + test("pull URI round-trips the exchange purse commitment", () => { + const purseCreateProof = { + exchangePub: "1".repeat(52), + exchangeSig: "2".repeat(103), + exchangeTimestamp: { t_s: 123 }, + totalDeposited: "KUDOS:0" as AmountString, + }; + const url = TalerUris.stringify({ + type: TalerUriAction.PayPull, + exchangeBaseUrl: "https://foo.example.com/" as HostPortPath, + contractPriv: VALID_PRIVATE_KEY, + purseCreateProof, + }); + const parsed = Result.orUndefined( + TalerUris.parseRestricted(url, TalerUriAction.PayPull), + ); + assert.deepStrictEqual(parsed?.purseCreateProof, purseCreateProof); + }); + + test("pull URI rejects a partial purse commitment", () => { + const url = `taler://pay-pull/exch.example.com/${VALID_PRIVATE_KEY}?exchange_pub=${"1".repeat(52)}`; + assert.ok( + Result.isError(TalerUris.parseRestricted(url, TalerUriAction.PayPull)), + ); }); /** @@ -731,11 +770,11 @@ import { AmountString } from "./types-taler-common.js"; aliasType: "email", sourceBaseUrl: "https://taldir.example.com", mailboxBaseUri: "https://mailbox.example.com/mb", - mailboxIdentity: "SOMEHASHOFPUBKEY", + mailboxIdentity: VALID_MAILBOX_KEY, }); assert.deepStrictEqual( url, - "taler://add-contact/email/bob@example.com/mailbox.example.com/mb/SOMEHASHOFPUBKEY?sourceBaseUrl=https%3A%2F%2Ftaldir.example.com", + `taler://add-contact/email/bob@example.com/mailbox.example.com/mb/${VALID_MAILBOX_KEY}?sourceBaseUrl=https%3A%2F%2Ftaldir.example.com`, ); }); @@ -773,15 +812,27 @@ import { AmountString } from "./types-taler-common.js"; test("parseAddContact keeps the mailbox sub-path", () => { const r = Result.orUndefined( TalerUris.parseRestricted( - "taler://add-contact/email/bob@example.com/mailbox.example.com/mb/SOMEHASHOFPUBKEY", + `taler://add-contact/email/bob@example.com/mailbox.example.com/mb/${VALID_MAILBOX_KEY}`, TalerUriAction.AddContact, ), ); assert.ok(r, "add-contact URI should parse"); - assert.deepStrictEqual(r.mailboxIdentity, "SOMEHASHOFPUBKEY"); + assert.deepStrictEqual(r.mailboxIdentity, VALID_MAILBOX_KEY); assert.deepStrictEqual(r.mailboxBaseUri, "https://mailbox.example.com/mb/"); }); +test("URI parsing rejects malformed financial capabilities and template fields", () => { + for (const uri of [ + "taler://pay-push/exchange.example/not-a-private-key", + "taler://pay-pull/exchange.example/not-a-private-key", + "taler://pay-template/merchant.example/", + "taler://pay-template/merchant.example/template?amount=not-an-amount", + "taler://add-contact/email/alice/mailbox.example/not-a-public-key", + ]) { + assert.ok(Result.isError(TalerUris.parse(uri)), uri); + } +}); + test("canonicalizeMerchantInstanceUrl folds the instance segment", () => { const canonical = "https://shop.example.com/instances/myshop/"; for (const variant of [ diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts @@ -28,7 +28,14 @@ import { Codec, Context, DecodingError, renderContext } from "./codec.js"; import { assertUnreachable } from "./errors.js"; import { HostPortPath, Paytos } from "./payto.js"; import { Result, ResultError, ResultOk } from "./result.js"; -import { AmountString, HashCodeString } from "./types-taler-common.js"; +import { decodeCrockFixed } from "./taler-crypto.js"; +import { + AmountString, + EddsaPrivateKeyString, + EddsaPublicKeyString, + EddsaSignatureString, +} from "./types-taler-common.js"; +import { TalerProtocolTimestamp } from "./time.js"; import { URL, URLSearchParams } from "./url.js"; export enum TalerUriAction { @@ -289,9 +296,21 @@ export namespace TalerUris { }); return result; } + case TalerUriAction.PayPull: { + const proof = p.purseCreateProof; + if (proof) { + result.push(["exchange_pub", proof.exchangePub]); + result.push(["exchange_sig", proof.exchangeSig]); + result.push([ + "exchange_timestamp", + String(proof.exchangeTimestamp.t_s), + ]); + result.push(["total_deposited", proof.totalDeposited]); + } + return result; + } case TalerUriAction.Refund: case TalerUriAction.PayPush: - case TalerUriAction.PayPull: case TalerUriAction.Restore: case TalerUriAction.AddExchange: { return result; @@ -419,14 +438,26 @@ export namespace TalerUris { pos: 0; } | { + uriType: TalerUriAction.PayPull; + pos: 1; + } + | { uriType: TalerUriAction.PayPush; pos: 0; } | { + uriType: TalerUriAction.PayPush; + pos: 1; + } + | { uriType: TalerUriAction.PayTemplate; pos: 0; } | { + uriType: TalerUriAction.PayTemplate; + pos: 1; + } + | { uriType: TalerUriAction.WithdrawExchange; pos: 0; } @@ -441,6 +472,10 @@ export namespace TalerUris { | { uriType: TalerUriAction.AddContact; pos: 0; + } + | { + uriType: TalerUriAction.AddContact; + pos: 1; }; export type ParseResult = @@ -699,10 +734,14 @@ function parsePayPush( } // get contract priv - const contractPriv = decodeUriSegment(cs[cs.length - 1]); // FIXME: validate private key + const contractPriv = decodeUriSegment(cs[cs.length - 1]); - if (!opts.ignoreComponentError && !contractPriv) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + if ( + !opts.ignoreComponentError && + (!contractPriv || !isFixedSizeCrock(contractPriv, 32)) + ) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 1 as const, uriType, }); } @@ -710,7 +749,7 @@ function parsePayPush( return Result.of({ type: TalerUriAction.PayPush, exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), - contractPriv, + contractPriv: contractPriv as EddsaPrivateKeyString, }); } @@ -718,7 +757,7 @@ function parsePayPull( scheme: "http" | "https", uriType: TalerUriAction.PayPull, cs: string[], - _params: Record<string, string>, + params: Record<string, string>, opts: TalerUris.PaytoParseOptions = {}, ): TalerUris.ParseResult { // check number of segments @@ -742,17 +781,56 @@ function parsePayPull( }); } // get contract priv - const contractPriv = decodeUriSegment(cs[cs.length - 1]); // FIXME: validate private key - if (!opts.ignoreComponentError && !contractPriv) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + const contractPriv = decodeUriSegment(cs[cs.length - 1]); + if ( + !opts.ignoreComponentError && + (!contractPriv || !isFixedSizeCrock(contractPriv, 32)) + ) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 1 as const, uriType, }); } + const proofFields = [ + params["exchange_pub"], + params["exchange_sig"], + params["exchange_timestamp"], + params["total_deposited"], + ]; + const proofFieldCount = proofFields.filter((x) => x !== undefined).length; + if ( + !opts.ignoreComponentError && + proofFieldCount !== 0 && + (proofFieldCount !== proofFields.length || + !isFixedSizeCrock(params["exchange_pub"], 32) || + !isFixedSizeCrock(params["exchange_sig"], 64) || + !Number.isSafeInteger(Number(params["exchange_timestamp"])) || + Number(params["exchange_timestamp"]) < 0 || + !Amounts.parse(params["total_deposited"])) + ) { + return Result.errorWithDetail(TalerUriParseError.INVALID_PARAMETER, { + uriType, + name: "purse creation proof", + }); + } + const purseCreateProof = + proofFieldCount === proofFields.length + ? { + exchangePub: params["exchange_pub"] as EddsaPublicKeyString, + exchangeSig: params["exchange_sig"] as EddsaSignatureString, + exchangeTimestamp: { + t_s: Number(params["exchange_timestamp"]), + }, + totalDeposited: params["total_deposited"] as AmountString, + } + : undefined; + return Result.of({ type: TalerUriAction.PayPull, exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), - contractPriv, + contractPriv: contractPriv as EddsaPrivateKeyString, + purseCreateProof, }); } @@ -828,10 +906,22 @@ function parsePayTemplate( }); } const templateId = decodeUriSegment(cs[cs.length - 1]); + if (!opts.ignoreComponentError && !templateId) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 1 as const, + uriType, + }); + } const amountParam = params["amount"]; let amount: AmountString | undefined; - if (amountParam != undefined && Amounts.checkString(amountParam)) { + if (amountParam !== undefined) { + if (!Amounts.checkString(amountParam)) { + return Result.errorWithDetail(TalerUriParseError.INVALID_PARAMETER, { + uriType, + name: "amount", + }); + } amount = amountParam; } @@ -1095,12 +1185,18 @@ function parseAddContact( }); } const mailboxIdentity = decodeUriSegment(cs[cs.length - 1]); + if (!mailboxIdentity || !isFixedSizeCrock(mailboxIdentity, 32)) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 1 as const, + uriType, + }); + } return Result.of({ type: TalerUriAction.AddContact, aliasType, alias, - mailboxIdentity, + mailboxIdentity: mailboxIdentity as EddsaPublicKeyString, mailboxBaseUri, sourceBaseUrl: params["sourceBaseUrl"], }); @@ -1159,13 +1255,21 @@ export interface TalerRefundUri { export interface TalerPayPushUri { type: TalerUriAction.PayPush; exchangeBaseUrl: HostPortPath; - contractPriv: string; + contractPriv: EddsaPrivateKeyString; } export interface TalerPayPullUri { type: TalerUriAction.PayPull; exchangeBaseUrl: HostPortPath; - contractPriv: string; + contractPriv: EddsaPrivateKeyString; + + /** Exchange-authenticated purse metadata, required before paying. */ + purseCreateProof?: { + totalDeposited: AmountString; + exchangeTimestamp: TalerProtocolTimestamp; + exchangeSig: EddsaSignatureString; + exchangePub: EddsaPublicKeyString; + }; } export interface TalerDevExperimentUri { @@ -1201,8 +1305,17 @@ export interface TalerAddContactUri { alias: string; aliasType: string; mailboxBaseUri: string; - mailboxIdentity: HashCodeString; - sourceBaseUrl: string; + mailboxIdentity: EddsaPublicKeyString; + sourceBaseUrl?: string; +} + +function isFixedSizeCrock(value: string, byteLength: number): boolean { + try { + decodeCrockFixed(value, byteLength); + return true; + } catch { + return false; + } } /** diff --git a/packages/taler-util/src/taleruris.test.ts b/packages/taler-util/src/taleruris.test.ts @@ -19,7 +19,14 @@ import assert from "node:assert"; import { HostPortPath } from "./payto.js"; import { Result } from "./result.js"; import { TalerUriAction, TalerUriParseError, TalerUris } from "./taleruri.js"; -import { AmountString } from "./types-taler-common.js"; +import { + AmountString, + EddsaPrivateKeyString, + EddsaPublicKeyString, +} from "./types-taler-common.js"; + +const VALID_PRIVATE_KEY = "0".repeat(52) as EddsaPrivateKeyString; +const VALID_MAILBOX_KEY = "1".repeat(52) as EddsaPublicKeyString; /** * 5.1 action: withdraw https://lsd.gnunet.org/lsd0006/#name-action-withdraw @@ -263,7 +270,7 @@ test("taler-new refund URI (stringify)", (t) => { */ test("taler-new peer to peer push URI", (t) => { - const url1 = "taler://pay-push/exch.example.com/foo"; + const url1 = `taler://pay-push/exch.example.com/${VALID_PRIVATE_KEY}`; // const r1 = parsePayPushUri(url1); const r1 = Result.unpack(TalerUris.parse(url1)); if (r1.type !== TalerUriAction.PayPush) { @@ -274,7 +281,7 @@ test("taler-new peer to peer push URI", (t) => { r1.exchangeBaseUrl, "https://exch.example.com/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler-new peer to peer push URI, merge_priv is mandatory", (t) => { @@ -287,7 +294,7 @@ test("taler-new peer to peer push URI, merge_priv is mandatory", (t) => { }); test("taler-new peer to peer push URI (path)", (t) => { - const url1 = "taler://pay-push/exch.example.com:123/bla/foo"; + const url1 = `taler://pay-push/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.unpack(TalerUris.parse(url1)); if (r1.type !== TalerUriAction.PayPush) { assert.fail(); @@ -297,11 +304,11 @@ test("taler-new peer to peer push URI (path)", (t) => { r1.exchangeBaseUrl, "https://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler-new peer to peer push URI (http)", (t) => { - const url1 = "taler+http://pay-push/exch.example.com:123/bla/foo"; + const url1 = `taler+http://pay-push/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.unpack(TalerUris.parse(url1)); if (r1.type !== TalerUriAction.PayPush) { assert.fail(); @@ -311,16 +318,19 @@ test("taler-new peer to peer push URI (http)", (t) => { r1.exchangeBaseUrl, "http://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler-new peer to peer push URI (stringify)", (t) => { const url = TalerUris.stringify({ type: TalerUriAction.PayPush, exchangeBaseUrl: "https://foo.example.com/bla/" as HostPortPath, - contractPriv: "123", + contractPriv: VALID_PRIVATE_KEY, }); - assert.deepStrictEqual(url, "taler://pay-push/foo.example.com/bla/123"); + assert.deepStrictEqual( + url, + `taler://pay-push/foo.example.com/bla/${VALID_PRIVATE_KEY}`, + ); }); /** @@ -328,7 +338,7 @@ test("taler-new peer to peer push URI (stringify)", (t) => { */ test("taler-new peer to peer pull URI", (t) => { - const url1 = "taler://pay-pull/exch.example.com/foo"; + const url1 = `taler://pay-pull/exch.example.com/${VALID_PRIVATE_KEY}`; // const r1 = parsePayPullUri(url1); const r1 = Result.unpack(TalerUris.parse(url1)); @@ -340,7 +350,7 @@ test("taler-new peer to peer pull URI", (t) => { r1.exchangeBaseUrl, "https://exch.example.com/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler-new peer to peer pull URI, contract_priv is mandatory", (t) => { @@ -353,7 +363,7 @@ test("taler-new peer to peer pull URI, contract_priv is mandatory", (t) => { }); test("taler-new peer to peer pull URI (path)", (t) => { - const url1 = "taler://pay-pull/exch.example.com:123/bla/foo"; + const url1 = `taler://pay-pull/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.unpack(TalerUris.parse(url1)); if (r1.type !== TalerUriAction.PayPull) { assert.fail(); @@ -363,11 +373,11 @@ test("taler-new peer to peer pull URI (path)", (t) => { r1.exchangeBaseUrl, "https://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler-new peer to peer pull URI (http)", (t) => { - const url1 = "taler+http://pay-pull/exch.example.com:123/bla/foo"; + const url1 = `taler+http://pay-pull/exch.example.com:123/bla/${VALID_PRIVATE_KEY}`; const r1 = Result.unpack(TalerUris.parse(url1)); if (r1.type !== TalerUriAction.PayPull) { assert.fail(); @@ -377,16 +387,19 @@ test("taler-new peer to peer pull URI (http)", (t) => { r1.exchangeBaseUrl, "http://exch.example.com:123/bla/" as HostPortPath, ); - assert.strictEqual(r1.contractPriv, "foo"); + assert.strictEqual(r1.contractPriv, VALID_PRIVATE_KEY); }); test("taler-new peer to peer pull URI (stringify)", (t) => { const url = TalerUris.stringify({ type: TalerUriAction.PayPull, exchangeBaseUrl: "https://foo.example.com/bla/" as HostPortPath, - contractPriv: "123", + contractPriv: VALID_PRIVATE_KEY, }); - assert.deepStrictEqual(url, "taler://pay-pull/foo.example.com/bla/123"); + assert.deepStrictEqual( + url, + `taler://pay-pull/foo.example.com/bla/${VALID_PRIVATE_KEY}`, + ); }); /** @@ -679,12 +692,12 @@ test("taler-new add contact URI (stringify)", (t) => { alias: "bob@example.com", aliasType: "email", mailboxBaseUri: "https://mailbox.example.com/mb", - mailboxIdentity: "SOMEHADDR", + mailboxIdentity: VALID_MAILBOX_KEY, sourceBaseUrl: "https://taldir.example.com", }); assert.deepStrictEqual( url, - "taler://add-contact/email/bob@example.com/mailbox.example.com/mb/SOMEHADDR?sourceBaseUrl=https%3A%2F%2Ftaldir.example.com", + `taler://add-contact/email/bob@example.com/mailbox.example.com/mb/${VALID_MAILBOX_KEY}?sourceBaseUrl=https%3A%2F%2Ftaldir.example.com`, ); }); diff --git a/packages/taler-util/src/types-taler-common.test.ts b/packages/taler-util/src/types-taler-common.test.ts @@ -181,3 +181,58 @@ test("a non-canonical exchange base URL from the bank is rejected", (t) => { codec.decode(status({ suggested_exchange: "https://ex.example/taler" })), ); }); + +test("coin history uses a closed, typed operation union", () => { + const response = { + balance: "KUDOS:4", + h_denom_pub: "denom-hash", + history: [ + { + type: "PURSE-DEPOSIT", + history_offset: 0, + amount: "KUDOS:1", + exchange_base_url: "https://exchange.example/", + deposit_fee: "KUDOS:0.01", + purse_pub: "purse", + refunded: false, + coin_sig: "coin-sig", + h_denom_pub: "denom-hash", + }, + ], + }; + const decoded = common.codecForCoinHistoryResponse().decode(response); + assert.strictEqual(decoded.history[0].type, "PURSE-DEPOSIT"); + assert.strictEqual(decoded.history[0].amount, "KUDOS:1"); + assert.throws(() => + common.codecForCoinHistoryResponse().decode({ + ...response, + history: [{ type: "UNKNOWN", history_offset: 0, amount: "KUDOS:1" }], + }), + ); + assert.throws(() => + common.codecForCoinHistoryResponse().decode({ + ...response, + history: [{ ...response.history[0], refunded: "yes" }], + }), + ); +}); + +test("pre-v32 melt history does not require a refresh seed", () => { + const decoded = common.codecForCoinHistoryResponse().decode({ + balance: "KUDOS:0", + h_denom_pub: "denom-hash", + history: [ + { + type: "MELT", + history_offset: 0, + amount: "KUDOS:1", + melt_fee: "KUDOS:0.01", + rc: "refresh-commitment", + h_denom_pub: "denom-hash", + coin_sig: "coin-signature", + }, + ], + }); + assert.strictEqual(decoded.history[0].type, "MELT"); + assert.strictEqual(decoded.history[0].refresh_seed, undefined); +}); diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -32,12 +32,13 @@ import { CancellationToken } from "./CancellationToken.js"; import { Codec, buildCodecForObject, - codecForAny, + buildCodecForUnion, codecForBoolean, codecForList, codecForMap, codecForNumber, codecForCanonicalBaseUrlString, + codecForConstString, codecForHttpUrlString, codecForString, codecOptional, @@ -268,14 +269,269 @@ export interface CoinHistoryResponse { h_denom_pub: HashCodeString; // Transaction history for the coin. - history: any[]; + history: CoinSpendHistoryItem[]; } +interface CoinHistoryItemCommon { + history_offset: Integer; +} + +export interface CoinDepositTransaction extends CoinHistoryItemCommon { + type: "DEPOSIT"; + amount: AmountString; + deposit_fee: AmountString; + merchant_pub: EddsaPublicKeyString; + timestamp: TalerProtocolTimestamp; + refund_deadline?: TalerProtocolTimestamp; + h_contract_terms: HashCodeString; + h_wire: HashCodeString; + h_denom_pub: HashCodeString; + h_policy?: HashCodeString; + wallet_data_hash?: HashCodeString; + h_age_commitment?: HashCodeString; + coin_sig: EddsaSignatureString; +} + +export interface CoinMeltTransaction extends CoinHistoryItemCommon { + type: "MELT"; + amount: AmountString; + melt_fee: AmountString; + rc: HashCodeString; + h_denom_pub: HashCodeString; + /** Added in exchange protocol v32. */ + refresh_seed?: string; + blinding_seed?: string; + h_age_commitment?: HashCodeString; + coin_sig: EddsaSignatureString; +} + +export interface CoinRefundTransaction extends CoinHistoryItemCommon { + type: "REFUND"; + amount: AmountString; + refund_fee: AmountString; + h_contract_terms: HashCodeString; + merchant_pub: EddsaPublicKeyString; + rtransaction_id: Integer; + merchant_sig: EddsaSignatureString; +} + +export interface CoinRecoupWithdrawTransaction extends CoinHistoryItemCommon { + type: "RECOUP-WITHDRAW"; + amount: AmountString; + exchange_sig: EddsaSignatureString; + exchange_pub: EddsaPublicKeyString; + coin_sig: EddsaSignatureString; + h_denom_pub: HashCodeString; + coin_blind: string; + reserve_pub: EddsaPublicKeyString; + timestamp: TalerProtocolTimestamp; +} + +export interface CoinRecoupRefreshTransaction extends CoinHistoryItemCommon { + type: "RECOUP-REFRESH"; + amount: AmountString; + exchange_sig: EddsaSignatureString; + exchange_pub: EddsaPublicKeyString; + old_coin_pub: EddsaPublicKeyString; + coin_sig: EddsaSignatureString; + coin_blind: string; + timestamp: TalerProtocolTimestamp; +} + +export interface CoinRecoupRefreshReceiverTransaction extends CoinHistoryItemCommon { + type: "RECOUP-REFRESH-RECEIVER"; + amount: AmountString; + timestamp: TalerProtocolTimestamp; + exchange_sig: EddsaSignatureString; + exchange_pub: EddsaPublicKeyString; + coin_pub: EddsaPublicKeyString; +} + +export interface CoinPurseDepositTransaction extends CoinHistoryItemCommon { + type: "PURSE-DEPOSIT"; + amount: AmountString; + exchange_base_url: string; + h_age_commitment?: HashCodeString; + deposit_fee: AmountString; + purse_pub: EddsaPublicKeyString; + refunded: boolean; + coin_sig: EddsaSignatureString; + h_denom_pub: HashCodeString; +} + +export interface CoinPurseRefundTransaction extends CoinHistoryItemCommon { + type: "PURSE-REFUND"; + amount: AmountString; + refund_fee: AmountString; + exchange_sig: EddsaSignatureString; + exchange_pub: EddsaPublicKeyString; + purse_pub: EddsaPublicKeyString; +} + +export interface CoinReserveOpenDepositTransaction extends CoinHistoryItemCommon { + type: "RESERVE-OPEN-DEPOSIT"; + coin_contribution: AmountString; + h_age_commitment?: HashCodeString; + reserve_sig: EddsaSignatureString; + coin_sig: EddsaSignatureString; +} + +export type CoinSpendHistoryItem = + | CoinDepositTransaction + | CoinMeltTransaction + | CoinRefundTransaction + | CoinRecoupWithdrawTransaction + | CoinRecoupRefreshTransaction + | CoinRecoupRefreshReceiverTransaction + | CoinPurseDepositTransaction + | CoinPurseRefundTransaction + | CoinReserveOpenDepositTransaction; + +export const codecForCoinSpendHistoryItem = (): Codec<CoinSpendHistoryItem> => + buildCodecForUnion<CoinSpendHistoryItem>() + .discriminateOn("type") + .alternative( + "DEPOSIT", + buildCodecForObject<CoinDepositTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("DEPOSIT")) + .property("amount", codecForAmountString()) + .property("deposit_fee", codecForAmountString()) + .property("merchant_pub", codecForString()) + .property("timestamp", codecForTimestamp) + .property("refund_deadline", codecOptional(codecForTimestamp)) + .property("h_contract_terms", codecForString()) + .property("h_wire", codecForString()) + .property("h_denom_pub", codecForString()) + .property("h_policy", codecOptional(codecForString())) + .property("wallet_data_hash", codecOptional(codecForString())) + .property("h_age_commitment", codecOptional(codecForString())) + .property("coin_sig", codecForString()) + .build("CoinDepositTransaction"), + ) + .alternative( + "MELT", + buildCodecForObject<CoinMeltTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("MELT")) + .property("amount", codecForAmountString()) + .property("melt_fee", codecForAmountString()) + .property("rc", codecForString()) + .property("h_denom_pub", codecForString()) + .property("refresh_seed", codecOptional(codecForString())) + .property("blinding_seed", codecOptional(codecForString())) + .property("h_age_commitment", codecOptional(codecForString())) + .property("coin_sig", codecForString()) + .build("CoinMeltTransaction"), + ) + .alternative( + "REFUND", + buildCodecForObject<CoinRefundTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("REFUND")) + .property("amount", codecForAmountString()) + .property("refund_fee", codecForAmountString()) + .property("h_contract_terms", codecForString()) + .property("merchant_pub", codecForString()) + .property("rtransaction_id", codecForNumber()) + .property("merchant_sig", codecForString()) + .build("CoinRefundTransaction"), + ) + .alternative( + "RECOUP-WITHDRAW", + buildCodecForObject<CoinRecoupWithdrawTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("RECOUP-WITHDRAW")) + .property("amount", codecForAmountString()) + .property("exchange_sig", codecForString()) + .property("exchange_pub", codecForString()) + .property("coin_sig", codecForString()) + .property("h_denom_pub", codecForString()) + .property("coin_blind", codecForString()) + .property("reserve_pub", codecForString()) + .property("timestamp", codecForTimestamp) + .build("CoinRecoupWithdrawTransaction"), + ) + .alternative( + "RECOUP-REFRESH", + buildCodecForObject<CoinRecoupRefreshTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("RECOUP-REFRESH")) + .property("amount", codecForAmountString()) + .property("exchange_sig", codecForString()) + .property("exchange_pub", codecForString()) + .property("old_coin_pub", codecForString()) + .property("coin_sig", codecForString()) + .property("coin_blind", codecForString()) + .property("timestamp", codecForTimestamp) + .build("CoinRecoupRefreshTransaction"), + ) + .alternative( + "RECOUP-REFRESH-RECEIVER", + buildCodecForObject<CoinRecoupRefreshReceiverTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("RECOUP-REFRESH-RECEIVER")) + .property("amount", codecForAmountString()) + .property("timestamp", codecForTimestamp) + .property("exchange_sig", codecForString()) + .property("exchange_pub", codecForString()) + .property("coin_pub", codecForString()) + .build("CoinRecoupRefreshReceiverTransaction"), + ) + .alternative( + "PURSE-DEPOSIT", + buildCodecForObject<CoinPurseDepositTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("PURSE-DEPOSIT")) + .property("amount", codecForAmountString()) + .property("exchange_base_url", codecForHttpUrlString()) + .property("h_age_commitment", codecOptional(codecForString())) + .property("deposit_fee", codecForAmountString()) + .property("purse_pub", codecForString()) + .property("refunded", codecForBoolean()) + .property("coin_sig", codecForString()) + .property("h_denom_pub", codecForString()) + .build("CoinPurseDepositTransaction"), + ) + .alternative( + "PURSE-REFUND", + buildCodecForObject<CoinPurseRefundTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("PURSE-REFUND")) + .property("amount", codecForAmountString()) + .property("refund_fee", codecForAmountString()) + .property("exchange_sig", codecForString()) + .property("exchange_pub", codecForString()) + .property("purse_pub", codecForString()) + .build("CoinPurseRefundTransaction"), + ) + .alternative( + "RESERVE-OPEN-DEPOSIT", + buildCodecForObject<CoinReserveOpenDepositTransaction>() + .allowExtra() + .property("history_offset", codecForNumber()) + .property("type", codecForConstString("RESERVE-OPEN-DEPOSIT")) + .property("coin_contribution", codecForAmountString()) + .property("h_age_commitment", codecOptional(codecForString())) + .property("reserve_sig", codecForString()) + .property("coin_sig", codecForString()) + .build("CoinReserveOpenDepositTransaction"), + ) + .build("CoinSpendHistoryItem"); + export const codecForCoinHistoryResponse = (): Codec<CoinHistoryResponse> => buildCodecForObject<CoinHistoryResponse>() .property("balance", codecForAmountString()) .property("h_denom_pub", codecForString()) - .property("history", codecForAny()) + .property("history", codecForList(codecForCoinSpendHistoryItem())) .build("CoinHistoryResponse"); export type BankTokenScope = diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -217,6 +217,24 @@ export interface RecoupRequest { coin_sig: string; ewv: ExchangeWithdrawValue; + + /** + * Hash over the blinded planchets in the original withdrawal request. + * Required by exchange protocol v26 and later. + */ + h_planchets: HashCodeString; + + /** + * Commitment used by the protocol-v25 withdrawal API. + * Kept optional for clients that only support v26 and later. + */ + withdraw_commitment_hash?: HashCodeString; + + /** Hash of the coin's age commitment, when age restriction was used. */ + h_age_commitment?: HashCodeString; + + /** Clause-Schnorr session nonce used to construct the coin envelope. */ + nonce?: HashCodeString; } export interface RecoupRefreshRequest { @@ -245,6 +263,12 @@ export interface RecoupRefreshRequest { coin_sig: string; ewv: ExchangeWithdrawValue; + + /** Hash of the coin's age commitment, when age restriction was used. */ + h_age_commitment?: HashCodeString; + + /** Clause-Schnorr session nonce used to construct the coin envelope. */ + nonce?: HashCodeString; } /** @@ -1462,7 +1486,7 @@ export interface ExchangeReservePurseRequest { // for the purse creation. Optional, if not present // the purse is to be created from the purse quota // of the reserve. - purse_fee: AmountString; + purse_fee?: AmountString; // Optional encrypted contract, in case the buyer is // proposing the contract and thus establishing the diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -2356,7 +2356,7 @@ export const codecForTestPayArgs = (): Codec<TestPayArgs> => .property("merchantAuthToken", codecOptional(codecForString())) .property("amount", codecForAmountString()) .property("summary", codecForString()) - .property("forcedCoinSel", codecForAny()) + .property("forcedCoinSel", codecOptional(codecForForcedCoinSel())) .build("TestPayArgs"); export interface IntegrationTestArgs { @@ -2769,7 +2769,7 @@ export const codecForGetChoicesForPaymentRequest = (): Codec<GetChoicesForPaymentRequest> => buildCodecForObject<GetChoicesForPaymentRequest>() .property("transactionId", codecForString()) - .property("forcedCoinSel", codecForAny()) + .property("forcedCoinSel", codecOptional(codecForForcedCoinSel())) .build("GetChoicesForPaymentRequest"); export enum ChoiceSelectionDetailType { @@ -2935,7 +2935,7 @@ export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> => buildCodecForObject<ConfirmPayRequest>() .property("transactionId", codecForTransactionIdStr()) .property("sessionId", codecOptional(codecForString())) - .property("forcedCoinSel", codecForAny()) + .property("forcedCoinSel", codecOptional(codecForForcedCoinSel())) .property("forcedTokenSel", codecOptional(codecForBoolean())) .property("choiceIndex", codecOptional(codecForNumber())) .property("useDonau", codecOptional(codecForBoolean())) @@ -3480,6 +3480,16 @@ export interface ForcedCoinSel { }[]; } +export function codecForForcedCoinSel(): Codec<ForcedCoinSel> { + const coinCodec = buildCodecForObject<ForcedCoinSel["coins"][number]>() + .property("value", codecForAmountString()) + .property("contribution", codecForAmountString()) + .build("ForcedCoin"); + return buildCodecForObject<ForcedCoinSel>() + .property("coins", codecForList(coinCodec)) + .build("ForcedCoinSel"); +} + export interface TestPayResult { /** * Number of coins used for the payment. @@ -3825,7 +3835,7 @@ export interface InitiatePeerPullCreditResponse { * * @deprecated since it's not necessarily valid yet until the tx is in the right state */ - talerUri: string; + talerUri?: string; transactionId: TransactionIdStr; } diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -1297,6 +1297,9 @@ async function handleTestingGetReserveHistory( req.reservePub, sigResp.sig, ); + if (resp.case !== "ok") { + throw Error(`reserve history request failed with HTTP ${resp.case}`); + } return resp.body; } diff --git a/packages/wallet-webui/src/testing/demo-wallet.ts b/packages/wallet-webui/src/testing/demo-wallet.ts @@ -60,7 +60,7 @@ const eurScope: ScopeInfo = { type: ScopeType.Global, currency: "EUR" }; export const demoActionUris: Record<DemoScenarioId, string> = { payment: "taler://pay/merchant.demo.invalid/order/session", withdrawal: "taler://withdraw/bank.demo.invalid/demo-operation", - "peer-receive": "taler://pay-push/exchange.demo.invalid/DEMO/RECEIVE", + "peer-receive": `taler://pay-push/exchange.demo.invalid/DEMO/${"0".repeat(52)}`, }; type DemoAccount = {