taler-typescript-core

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

commit 931b3646292ec88017cc0ce32a200210ff21b385
parent 5625488fd3ac4e40bbc262e783670db9eb3ada4c
Author: Florian Dold <florian@dold.me>
Date:   Mon, 22 Sep 2025 19:34:50 +0200

wallet-core: implement MVP donau support

Diffstat:
Mpackages/taler-harness/src/harness/environments.ts | 6+++++-
Mpackages/taler-harness/src/harness/harness-donau.ts | 27+++++++++++++++------------
Mpackages/taler-harness/src/harness/harness.ts | 60+++++++++++++++++++++++++++++++++++++++++++++++++-----------
Apackages/taler-harness/src/integrationtests/test-donau-minus-t.ts | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-donau.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/amounts.ts | 12++++++++++++
Mpackages/taler-util/src/http-client/donau-client.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 23+++++++++++++++++++++++
Mpackages/taler-util/src/index.ts | 1+
Mpackages/taler-util/src/types-donau.ts | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackages/taler-util/src/types-taler-merchant.ts | 5+++++
Mpackages/taler-util/src/types-taler-wallet.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 27+++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/db.ts | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/donau.ts | 467++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-merchant.ts | 155++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/testing.ts | 18+++++++-----------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 9+++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 20++++++++++++--------
20 files changed, 1384 insertions(+), 115 deletions(-)

diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts @@ -158,6 +158,8 @@ export interface EnvOptions { */ forceLibeufin?: boolean; + merchantUseDonau?: boolean; + walletConfig?: PartialWalletRunConfig; additionalExchangeConfig?(e: ExchangeService): void; @@ -600,7 +602,9 @@ export async function createSimpleTestkudosEnvironmentV3( if (opts.additionalMerchantConfig) { opts.additionalMerchantConfig(merchant); } - await merchant.start(); + await merchant.start({ + useDonau: opts.merchantUseDonau, + }); await merchant.pingUntilAvailable(); const { accessToken: merchantAdminAccessToken } = diff --git a/packages/taler-harness/src/harness/harness-donau.ts b/packages/taler-harness/src/harness/harness-donau.ts @@ -109,16 +109,21 @@ export class DonauService { config.setString("donau", "base_url", `http://${hostname}:${e.httpPort}/`); config.setString("donau", "serve", "tcp"); config.setString("donau", "port", `${e.httpPort}`); - config.setString("donau", "domain", e.domain); - config.setString("donau", "EXPIRE_LEGAL", "5"); + config.setString("donau", "legal_domain", e.domain); + config.setString("donau", "expire_legal_years", "5"); config.setString("donaudb-postgres", "config", e.database); // Limit signing lookahead to make the test startup faster. + config.setString("donau-secmod-cs", "lookahead_sign", "24 days"); - config.setString("donau-secmod-eddsa", "lookahead_sign", "24 days"); + config.setString("donau-secmod-cs", "overlap_duration", "0"); + config.setString("donau-secmod-rsa", "lookahead_sign", "24 days"); + config.setString("donau-secmod-rsa", "overlap_duration", "0"); + config.setString("donau-secmod-eddsa", "lookahead_sign", "24 days"); + config.setString("donau-secmod-eddsa", "duration", "14 days"); const cfgFilename = testDir + `/donau-${e.name}.conf`; config.writeTo(cfgFilename, { excludeDefaults: true }); @@ -270,7 +275,7 @@ export class DonauService { * @param name additional component name, needed when launching multiple instances of the same component */ function setDonauPaths(config: Configuration, home: string, name?: string) { - config.setString("paths", "taler_home", home); + config.setString("paths", "donau_home", home); // We need to make sure that the path of taler_runtime_dir isn't too long, // as it contains unix domain sockets (108 character limit). const extraName = name != null ? `${name}-` : ""; @@ -281,23 +286,21 @@ function setDonauPaths(config: Configuration, home: string, name?: string) { "donau_data_home", "$DONAU_HOME/.local/share/donau/", ); - config.setString("paths", "donau_config_home", "$TALER_HOME/.config/donau/"); - config.setString("paths", "donau_cache_home", "$TALER_HOME/.config/donau/"); + config.setString("paths", "donau_config_home", "$DONAU_HOME/.config/donau/"); + config.setString("paths", "donau_cache_home", "$DONAU_HOME/.config/donau/"); } function setDonauCoin(config: Configuration, c: CoinConfig) { const s = `doco_${c.name}`; config.setString(s, "value", c.value); - config.setString(s, "duration_withdraw", c.durationWithdraw); - config.setString(s, "duration_spend", c.durationSpend); - config.setString(s, "duration_legal", c.durationLegal); + config.setString(s, "duration_withdraw", " 1 year"); + config.setString(s, "anchor_round", " 1 year"); + config.setString(s, "duration_spend", "2 years"); + config.setString(s, "duration_legal", "3 years"); config.setString(s, "fee_deposit", c.feeDeposit); config.setString(s, "fee_withdraw", c.feeWithdraw); config.setString(s, "fee_refresh", c.feeRefresh); config.setString(s, "fee_refund", c.feeRefund); - if (c.ageRestricted) { - config.setString(s, "age_restricted", "yes"); - } if (c.cipher === "RSA") { config.setString(s, "rsa_keysize", `${c.rsaKeySize}`); config.setString(s, "cipher", "RSA"); diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -693,7 +693,7 @@ class BankServiceBase { protected globalTestState: GlobalTestState, protected bankConfig: BankConfig, protected configFile: string, - ) { } + ) {} getAdminAuth(): { username: string; password: string } { // Bank admin PW is brutally hard-coded in tests right now. @@ -727,7 +727,8 @@ export interface HarnessExchangeBankAccount { */ export class FakebankService extends BankServiceBase - implements BankServiceHandle { + implements BankServiceHandle +{ proc: ProcessWrapper | undefined; http = createPlatformHttpLib({ enableThrottling: false }); @@ -921,7 +922,7 @@ export class LibeufinNexusService { private gc: GlobalTestState, private bc: NexusConfig, private configFile: string, - ) { } + ) {} async dbinit(): Promise<void> { await sh( @@ -937,7 +938,8 @@ export class LibeufinNexusService { */ export class LibeufinBankService extends BankServiceBase - implements BankServiceHandle { + implements BankServiceHandle +{ proc: ProcessWrapper | undefined; http = createPlatformHttpLib({ enableThrottling: false }); @@ -1523,7 +1525,7 @@ export class ExchangeService implements ExchangeServiceInterface { private exchangeConfig: ExchangeConfig, private configFilename: string, private keyPair: EddsaKeyPair, - ) { } + ) {} get name() { return this.exchangeConfig.name; @@ -1987,13 +1989,14 @@ export class MerchantService implements MerchantServiceInterface { procHttpd: ProcessWrapper | undefined; procExchangekeyupdate: ProcessWrapper | undefined; + procDonaukeyupdate: ProcessWrapper | undefined; procKyccheck: ProcessWrapper | undefined; constructor( private globalState: GlobalTestState, private merchantConfig: MerchantConfig, private configFilename: string, - ) { } + ) {} private currentTimetravelOffsetMs: number | undefined; @@ -2054,6 +2057,14 @@ export class MerchantService implements MerchantServiceInterface { logger.info(`done killing merchant exchangekeyupdate`); this.procExchangekeyupdate = undefined; } + const donaukeyupdate = this.procDonaukeyupdate; + if (donaukeyupdate) { + logger.info(`killing merchant donaukeyupdate`); + donaukeyupdate.proc.kill("SIGTERM"); + await donaukeyupdate.wait(); + logger.info(`done killing merchant donaukeyupdate`); + this.procDonaukeyupdate = undefined; + } const kyccheck = this.procKyccheck; if (kyccheck) { logger.info(`killing merchant kyccheck`); @@ -2104,11 +2115,22 @@ export class MerchantService implements MerchantServiceInterface { ); } + async runDonaukeyupdateOnce() { + await runCommand( + this.globalState, + `merchant-${this.name}-donaukeyupdate-once`, + "taler-merchant-donaukeyupdate", + [...this.timetravelArgArr, "-LTRACER", "-c", this.configFilename, "-t"], + ); + } + /** * Start the merchant. * Waits for the service to become fully available. */ - async start(opts: { skipDbinit?: boolean } = {}): Promise<void> { + async start( + opts: { skipDbinit?: boolean; useDonau?: boolean } = {}, + ): Promise<void> { const skipSetup = opts.skipDbinit ?? false; if (!skipSetup) { @@ -2139,6 +2161,20 @@ export class MerchantService implements MerchantServiceInterface { `merchant-exchangekeyupdate-${this.merchantConfig.name}`, ); + if (opts.useDonau) { + this.procDonaukeyupdate = this.globalState.spawnService( + "taler-merchant-donaukeyupdate", + [ + "taler-merchant-donaukeyupdate", + "-LDEBUG", + "-c", + this.configFilename, + ...this.timetravelArgArr, + ], + `merchant-donaukeyupdate-${this.merchantConfig.name}`, + ); + } + this.procKyccheck = this.globalState.spawnService( "taler-merchant-kyccheck", [ @@ -2588,7 +2624,7 @@ export class WalletClient { return client.call(operation, payload); } - constructor(private args: WalletClientArgs) { } + constructor(private args: WalletClientArgs) {} async connect(): Promise<void> { const waiter = this.waiter; @@ -2670,9 +2706,11 @@ export class WalletCli { ? `--crypto-worker=${cliOpts.cryptoWorkerType}` : ""; const logName = `wallet-${self.name}`; - const command = `taler-wallet-cli ${self.timetravelArg ?? "" - } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${self.dbfile - }' api '${op}' ${shellWrap(JSON.stringify(payload))}`; + const command = `taler-wallet-cli ${ + self.timetravelArg ?? "" + } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${ + self.dbfile + }' api '${op}' ${shellWrap(JSON.stringify(payload))}`; const resp = await sh(self.globalTestState, logName, command); logger.info("--- wallet core response ---"); logger.info(resp); diff --git a/packages/taler-harness/src/integrationtests/test-donau-minus-t.ts b/packages/taler-harness/src/integrationtests/test-donau-minus-t.ts @@ -0,0 +1,208 @@ +/* + This file is part of GNU Taler + (C) 2020-2025 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + ConfirmPayResultType, + DonauHttpClient, + j2s, + MerchantContractOutputType, + MerchantContractVersion, + OrderOutputType, + OrderVersion, + PreparePayResultType, + succeedOrThrow, + TalerMerchantInstanceHttpClient, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + createSimpleTestkudosEnvironmentV3, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { DonauService } from "../harness/harness-donau.js"; +import { delayMs, GlobalTestState } from "../harness/harness.js"; + +export async function runDonauMinusTTest(t: GlobalTestState) { + // Set up test environment + + const { + walletClient, + bankClient, + exchange, + merchant, + merchantAdminAccessToken, + commonDb, + } = await createSimpleTestkudosEnvironmentV3(t, undefined, { + walletConfig: { + features: { + enableV1Contracts: true, + }, + }, + }); + + const wres = await withdrawViaBankV3(t, { + walletClient, + bankClient, + amount: "TESTKUDOS:20", + exchange, + }); + await wres.withdrawalFinishedCond; + + const donau = DonauService.create(t, { + currency: "TESTKUDOS", + database: commonDb.connStr, + httpPort: 8084, + name: "donau", + domain: "Bern", + }); + + donau.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); + + await donau.start(); + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + + const inst = succeedOrThrow( + await merchantClient.getCurrentInstanceDetails(merchantAdminAccessToken), + ); + const merchantPub = inst.merchant_pub; + + const donauClient = new DonauHttpClient(donau.baseUrl); + + const currentYear = new Date().getFullYear(); + + const charityResp = succeedOrThrow( + await donauClient.postCharity({ + body: { + charity_pub: merchantPub, + current_year: currentYear, + max_per_year: "TESTKUDOS:1000", + charity_name: "42", + receipts_to_date: "TESTKUDOS:0", + charity_url: merchant.makeInstanceBaseUrl(), + }, + }), + ); + + const config = await donauClient.getConfig(); + console.log(`config: ${j2s(config)}`); + + const keys = await donauClient.getKeys(); + console.log(`keys: ${j2s(keys)}`); + + const charityId = charityResp["charity_id"]; + + succeedOrThrow( + await merchantClient.postDonau({ + body: { + charity_id: charityId, + donau_url: donau.baseUrl, + }, + token: merchantAdminAccessToken, + }), + ); + + await merchant.runDonaukeyupdateOnce(); + + const orderResp = succeedOrThrow( + await merchantClient.createOrder(merchantAdminAccessToken, { + order: { + version: OrderVersion.V1, + summary: "Test Donation", + choices: [ + { + amount: "TESTKUDOS:9", + outputs: [ + { + type: OrderOutputType.TaxReceipt, + amount: "TESTKUDOS:9", + donau_urls: [donau.baseUrl], + }, + ], + }, + ], + }, + }), + ); + + console.log(`order resp: ${j2s(orderResp)}`); + + let orderStatus = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + orderResp.order_id, + ), + ); + + t.assertTrue(orderStatus.order_status === "unpaid"); + + await walletClient.call(WalletApiOperation.SetDonau, { + donauBaseUrl: donau.baseUrl, + taxPayerId: "test-tax-payer", + }); + + const preparePayResult = await walletClient.call( + WalletApiOperation.PreparePayForUri, + { + talerPayUri: orderStatus.taler_pay_uri, + }, + ); + + console.log(`preparePayResult: ${j2s(preparePayResult)}`); + + t.assertTrue( + preparePayResult.status === PreparePayResultType.ChoiceSelection, + ); + + t.assertDeepEqual( + preparePayResult.contractTerms.version, + MerchantContractVersion.V1, + ); + const outTok = preparePayResult.contractTerms.choices[0].outputs[0]; + t.assertDeepEqual(outTok.type, MerchantContractOutputType.TaxReceipt); + + t.assertDeepEqual(outTok.donau_urls, [donau.baseUrl]); + + // t.assertTrue(!!outTok.amount); + + // t.assertAmountEquals(outTok.amount, "TESTKUDOS:5"); + + const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: preparePayResult.transactionId, + choiceIndex: 0, + useDonau: true, + }); + + t.assertDeepEqual(r2.type, ConfirmPayResultType.Done); + + // Check if payment was successful. + + orderStatus = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + orderResp.order_id, + ), + ); + + t.assertDeepEqual(orderStatus.order_status, "paid"); +} + +runDonauMinusTTest.suites = ["donau"]; diff --git a/packages/taler-harness/src/integrationtests/test-donau.ts b/packages/taler-harness/src/integrationtests/test-donau.ts @@ -21,6 +21,8 @@ import { ConfirmPayResultType, DonauHttpClient, j2s, + MerchantContractOutputType, + MerchantContractVersion, OrderOutputType, OrderVersion, PreparePayResultType, @@ -34,7 +36,7 @@ import { withdrawViaBankV3, } from "../harness/environments.js"; import { DonauService } from "../harness/harness-donau.js"; -import { GlobalTestState } from "../harness/harness.js"; +import { delayMs, GlobalTestState } from "../harness/harness.js"; export async function runDonauTest(t: GlobalTestState) { // Set up test environment @@ -46,7 +48,14 @@ export async function runDonauTest(t: GlobalTestState) { merchant, merchantAdminAccessToken, commonDb, - } = await createSimpleTestkudosEnvironmentV3(t); + } = await createSimpleTestkudosEnvironmentV3(t, undefined, { + merchantUseDonau: true, + walletConfig: { + features: { + enableV1Contracts: true, + }, + }, + }); const wres = await withdrawViaBankV3(t, { walletClient, @@ -57,7 +66,7 @@ export async function runDonauTest(t: GlobalTestState) { await wres.withdrawalFinishedCond; const donau = DonauService.create(t, { - currency: "KUDOS", + currency: "TESTKUDOS", database: commonDb.connStr, httpPort: 8084, name: "donau", @@ -68,18 +77,54 @@ export async function runDonauTest(t: GlobalTestState) { await donau.start(); + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + + const inst = succeedOrThrow( + await merchantClient.getCurrentInstanceDetails(merchantAdminAccessToken), + ); + const merchantPub = inst.merchant_pub; + const donauClient = new DonauHttpClient(donau.baseUrl); + const currentYear = new Date().getFullYear(); + + const charityResp = succeedOrThrow( + await donauClient.postCharity({ + body: { + charity_pub: merchantPub, + current_year: currentYear, + max_per_year: "TESTKUDOS:1000", + charity_name: "42", + receipts_to_date: "TESTKUDOS:0", + charity_url: merchant.makeInstanceBaseUrl(), + }, + }), + ); + const config = await donauClient.getConfig(); console.log(`config: ${j2s(config)}`); const keys = await donauClient.getKeys(); console.log(`keys: ${j2s(keys)}`); - const merchantClient = new TalerMerchantInstanceHttpClient( - merchant.makeInstanceBaseUrl(), + const charityId = charityResp["charity_id"]; + + succeedOrThrow( + await merchantClient.postDonau({ + body: { + charity_id: charityId, + donau_url: donau.baseUrl, + }, + token: merchantAdminAccessToken, + }), ); + // Wait for donaukeysupdate + // We don't use -t since it doesn't seem to work at the moment. + await delayMs(2000); + const orderResp = succeedOrThrow( await merchantClient.createOrder(merchantAdminAccessToken, { order: { @@ -87,11 +132,11 @@ export async function runDonauTest(t: GlobalTestState) { summary: "Test Donation", choices: [ { - amount: "TESTKUDOS:10", + amount: "TESTKUDOS:9", outputs: [ { type: OrderOutputType.TaxReceipt, - amount: "TESTKUDOS:5", + amount: "TESTKUDOS:9", donau_urls: [donau.baseUrl], }, ], @@ -112,6 +157,11 @@ export async function runDonauTest(t: GlobalTestState) { t.assertTrue(orderStatus.order_status === "unpaid"); + await walletClient.call(WalletApiOperation.SetDonau, { + donauBaseUrl: donau.baseUrl, + taxPayerId: "test-tax-payer", + }); + const preparePayResult = await walletClient.call( WalletApiOperation.PreparePayForUri, { @@ -119,12 +169,29 @@ export async function runDonauTest(t: GlobalTestState) { }, ); + console.log(`preparePayResult: ${j2s(preparePayResult)}`); + t.assertTrue( - preparePayResult.status === PreparePayResultType.PaymentPossible, + preparePayResult.status === PreparePayResultType.ChoiceSelection, ); + t.assertDeepEqual( + preparePayResult.contractTerms.version, + MerchantContractVersion.V1, + ); + const outTok = preparePayResult.contractTerms.choices[0].outputs[0]; + t.assertDeepEqual(outTok.type, MerchantContractOutputType.TaxReceipt); + + t.assertDeepEqual(outTok.donau_urls, [donau.baseUrl]); + + // t.assertTrue(!!outTok.amount); + + // t.assertAmountEquals(outTok.amount, "TESTKUDOS:5"); + const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, { transactionId: preparePayResult.transactionId, + choiceIndex: 0, + useDonau: true, }); t.assertDeepEqual(r2.type, ConfirmPayResultType.Done); @@ -139,6 +206,12 @@ export async function runDonauTest(t: GlobalTestState) { ); t.assertDeepEqual(orderStatus.order_status, "paid"); + + const statements = await walletClient.call( + WalletApiOperation.GetDonauStatements, + {}, + ); + console.log(j2s(statements)); } runDonauTest.suites = ["donau"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -52,6 +52,7 @@ import { runDepositMergeTest } from "./test-deposit-merge.js"; import { runDepositTooLargeTest } from "./test-deposit-too-large.js"; import { runDepositTest } from "./test-deposit.js"; import { runDonauCompatTest } from "./test-donau-compat.js"; +import { runDonauMinusTTest } from "./test-donau-minus-t.js"; import { runDonauTest } from "./test-donau.js"; import { runExchangeDepositTest } from "./test-exchange-deposit.js"; import { runExchangeKycAuthTest } from "./test-exchange-kyc-auth.js"; @@ -381,6 +382,7 @@ const allTests: TestMainFunction[] = [ runDenomLostComplexTest, runDonauCompatTest, runDonauTest, + runDonauMinusTTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts @@ -96,6 +96,18 @@ export class Amount { return new Amount(r.amount, r.saturated ? 1 : 0); } + isZero(): boolean { + return this.val.fraction === 0 && this.val.value === 0; + } + + sub(...a: AmountLike[]): Amount { + if (this.saturated) { + return this; + } + const r = Amounts.sub(this.val, ...a); + return new Amount(r.amount, r.saturated ? 1 : 0); + } + mult(n: number): Amount { if (this.saturated) { return this; diff --git a/packages/taler-util/src/http-client/donau-client.ts b/packages/taler-util/src/http-client/donau-client.ts @@ -40,10 +40,15 @@ import { } from "../index.js"; import { TalerErrorCode } from "../taler-error-codes.js"; import { + CharityRequest, + codecForDonauCharityResponse, + codecForDonauDonationStatementResponse, codecForDonauKeysResponse, codecForDonauVersionResponse, + DonauCharityResponse, DonauKeysResponse, DonauVersionResponse, + SubmitDonationReceiptsRequest, } from "../types-donau.js"; /** @@ -148,6 +153,52 @@ export class DonauHttpClient { } } + async postCharity(args: { + body: CharityRequest; + }): Promise<OperationOk<DonauCharityResponse>> { + const resp = await this.fetch("charities", { + method: "POST", + body: args.body, + }); + switch (resp.status) { + case HttpStatusCode.Created: + return opSuccessFromHttp(resp, codecForDonauCharityResponse()); + default: + return opUnknownHttpFailure(resp); + } + } + + async postBatchSubmit(args: { body: SubmitDonationReceiptsRequest }) { + const resp = await this.fetch("batch-submit", { + method: "POST", + body: args.body, + }); + switch (resp.status) { + case HttpStatusCode.Created: + return opFixedSuccess({}); + default: + return opUnknownHttpFailure(resp); + } + } + + async getDonationStatement(args: { year: number; taxIdHash: string }) { + const resp = await this.fetch( + `donation-statement/${args.year}/${args.taxIdHash}`, + { + method: "GET", + }, + ); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp( + resp, + codecForDonauDonationStatementResponse(), + ); + default: + return opUnknownHttpFailure(resp); + } + } + async getKeys(): Promise<OperationOk<DonauKeysResponse>> { const resp = await this.fetch("keys"); switch (resp.status) { diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -22,6 +22,7 @@ import { LibtoolVersion, LoginTokenRequest, ChallengeSolveRequest, + MerchantPostDonauBody, OperationAlternative, OperationFail, OperationOk, @@ -2683,6 +2684,28 @@ export class TalerMerchantInstanceHttpClient { } } + async postDonau(args: { body: MerchantPostDonauBody; token?: AccessToken }) { + const headers: Record<string, string> = {}; + if (args.token) { + headers.Authorization = makeBearerTokenAuthHeader(args.token); + } + const url = new URL(`private/donau`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + headers, + body: args.body, + }); + switch (resp.status) { + case HttpStatusCode.NoContent: + case HttpStatusCode.Created: + case HttpStatusCode.Ok: { + return opEmptySuccess(); + } + default: + return opUnknownHttpFailure(resp); + } + } + /** * Get the auth api against the current instance * diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts @@ -65,6 +65,7 @@ export * from "./types-taler-bank-conversion.js"; export * from "./types-taler-bank-integration.js"; export * from "./types-taler-exchange.js"; export * from "./types-taler-merchant.js"; +export * from "./types-donau.js"; // end export * from "./types-taler-common.js"; diff --git a/packages/taler-util/src/types-donau.ts b/packages/taler-util/src/types-donau.ts @@ -24,6 +24,7 @@ import { } from "./codec.js"; import { AmountString, + codecForAmountString, codecForAny, codecForEddsaPublicKey, codecForEddsaSignature, @@ -31,9 +32,14 @@ import { codecForNumber, codecForTimestamp, Cs25519Point, + Cs25519Scalar, + DenomKeyType, EddsaPublicKeyString, EddsaSignatureString, + HashCodeString, + Integer, RsaPublicKeyString, + RsaSignature, TalerProtocolTimestamp, } from "./index.js"; @@ -117,60 +123,30 @@ export interface DonationUnitKeyGroupCommon { // How much are coins of this denomination worth? value: AmountString; - // Fee charged by the exchange for withdrawing a coin of this denomination. - fee_withdraw: AmountString; + // For which year is this donation unit key valid. + year: number; - // Fee charged by the exchange for depositing a coin of this denomination. - fee_deposit: AmountString; - - // Fee charged by the exchange for refreshing a coin of this denomination. - fee_refresh: AmountString; - - // Fee charged by the exchange for refunding a coin of this denomination. - fee_refund: AmountString; -} - -export interface DonationUnitKeyCommon { - // Signature of TALER_DenominationKeyValidityPS. - master_sig: EddsaSignatureString; - - // When does the denomination key become valid? - stamp_start: TalerProtocolTimestamp; - - // When is it no longer possible to deposit coins - // of this denomination? - stamp_expire_withdraw: TalerProtocolTimestamp; - - // Timestamp indicating by when legal disputes relating to these coins must - // be settled, as the exchange will afterwards destroy its evidence relating to - // transactions involving this coin. - stamp_expire_legal: TalerProtocolTimestamp; - - stamp_expire_deposit: TalerProtocolTimestamp; - - // Set to 'true' if the exchange somehow "lost" - // the private key. The denomination was not - // necessarily revoked, but still cannot be used - // to withdraw coins at this time (theoretically, - // the private key could be recovered in the - // future; coins signed with the private key + // Set to 'true' if the Donau somehow "lost" the private key. The donation unit was not + // revoked, but still cannot be used to withdraw receipts at this time (theoretically, + // the private key could be recovered in the future; receipts signed with the private key // remain valid). lost?: boolean; } export interface DonationUnitKeyGroupRsa extends DonationUnitKeyGroupCommon { - cipher: "RSA"; - - denoms: ({ - rsa_pub: RsaPublicKeyString; - } & DonationUnitKeyCommon)[]; + donation_unit_pub: { + cipher: "RSA"; + pub_key_hash: HashCodeString; + rsa_public_key: RsaPublicKeyString; + }; } export interface DonationUnitKeyGroupCs extends DonationUnitKeyGroupCommon { - cipher: "CS"; - denoms: ({ + donation_unit_pub: { + cipher: "CS"; + pub_key_hash: HashCodeString; cs_pub: Cs25519Point; - } & DonationUnitKeyCommon)[]; + }; } // FIXME: Validate properly! @@ -187,3 +163,114 @@ export const codecForDonauKeysResponse = (): Codec<DonauKeysResponse> => .property("donation_units", codecForList(codecForDonationUnitKeyGroup)) //.property("currency_fraction_digits", codecForNumber()) .build("DonauApi.DonauKeysResponse"); + +export interface BlindedDonationReceiptKeyPair { + // Hash of the public key that should be used to sign + // the donation receipt. + h_donation_unit_pub: HashCodeString; + + // Blinded value to give to the Donau to sign over. + blinded_udi: BlindedUniqueDonationIdentifier; +} + +export type BlindedUniqueDonationIdentifier = RSABUDI | CSBUDI; + +export interface RSABUDI { + cipher: "RSA"; + rsa_blinded_identifier: string; // Crockford Base32 encoded +} + +// For donation unit signatures based on Blind Clause-Schnorr, the BUDI +// consists of the public nonce and two Curve25519 scalars which are two +// blinded challenges in the Blinded Clause-Schnorr signature scheme. +// See https://taler.net/papers/cs-thesis.pdf for details. +export interface CSBUDI { + cipher: "CS"; + cs_nonce: string; // Crockford Base32 encoded + cs_blinded_c0: string; // Crockford Base32 encoded + cs_blinded_c1: string; // Crockford Base32 encoded +} + +export type DonationReceiptSignature = + | RSADonationReceiptSignature + | CSDonationReceiptSignature; + +export interface RSADonationReceiptSignature { + cipher: "RSA"; + + // RSA signature + rsa_signature: RsaSignature; +} + +export interface CSDonationReceiptSignature { + cipher: "CS"; + + // R value component of the signature. + cs_signature_r: Cs25519Point; + + // s value component of the signature. + cs_signature_s: Cs25519Scalar; +} + +export type DonauUnitPubKey = RsaDonauUnitPubKey | CsDonauUnitPubKey; + +export interface RsaDonauUnitPubKey { + readonly cipher: DenomKeyType.Rsa; + readonly rsa_public_key: string; + readonly age_mask: number; +} + +export interface CsDonauUnitPubKey { + readonly cipher: DenomKeyType.ClauseSchnorr; + readonly age_mask: number; + readonly cs_public_key: string; +} + +export interface CharityRequest { + charity_pub: EddsaPublicKeyString; + charity_name: string; + charity_url: string; + max_per_year: AmountString; + receipts_to_date: AmountString; + current_year: Integer; +} + +export interface DonauCharityResponse { + charity_id: Integer; +} + +export const codecForDonauCharityResponse = (): Codec<DonauCharityResponse> => + buildCodecForObject<DonauCharityResponse>() + .property("charity_id", codecForNumber()) + .build("DonauApi.DonauCharityResponse"); + +export interface SubmitDonationReceiptsRequest { + // hashed taxpayer ID plus salt + h_donor_tax_id: HashCodeString; + // All donation receipts must be for this year. + donation_year: Integer; + // Receipts should be sorted by amount. + donation_receipts: DonationReceipt[]; +} + +export interface DonationReceipt { + h_donation_unit_pub: HashCodeString; + nonce: string; + donation_unit_sig: DonationReceiptSignature; +} + +export interface DonationStatementResponse { + total: AmountString; + // signature over h_donor_tax_id, total, donation_year + donation_statement_sig: EddsaSignatureString; + // the corresponding public key to the signature + donau_pub: EddsaPublicKeyString; +} + +export const codecForDonauDonationStatementResponse = + (): Codec<DonationStatementResponse> => + buildCodecForObject<DonationStatementResponse>() + .property("total", codecForAmountString()) + .property("donau_pub", codecForEddsaPublicKey()) + .property("donation_statement_sig", codecForEddsaSignature()) + .build("DonauApi.DonationStatementResponse"); diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -4429,3 +4429,8 @@ export interface ChallengeSolveRequest { // The TAN code that solves $CHALLENGE_ID. tan: string; } + +export interface MerchantPostDonauBody { + donau_url: string; + charity_id: number; +} diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -52,6 +52,8 @@ import { CurrencySpecification, DurationUnitSpec, EddsaPrivateKeyString, + EddsaPublicKeyString, + EddsaSignatureString, HashCode, TalerMerchantApi, TemplateParams, @@ -72,6 +74,7 @@ import { codecForPreciseTimestamp, codecForTimestamp, } from "./time.js"; +import { BlindedDonationReceiptKeyPair } from "./types-donau.js"; import { AccountRestriction, AuditorDenomSig, @@ -2491,6 +2494,7 @@ export const codecForPreparePayTemplateRequest = export interface ConfirmPayRequest { transactionId: TransactionIdStr; + useDonau?: boolean; sessionId?: string; forcedCoinSel?: ForcedCoinSel; @@ -2515,6 +2519,7 @@ export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> => .property("forcedCoinSel", codecForAny()) .property("forcedTokenSel", codecOptional(codecForBoolean())) .property("choiceIndex", codecOptional(codecForNumber())) + .property("useDonau", codecOptional(codecForBoolean())) .build("ConfirmPay"); export interface CoreApiRequestEnvelope { @@ -3637,8 +3642,28 @@ export interface DownloadedContractData { export type PayWalletData = { choice_index?: number; tokens_evs: TokenEnvelope[]; + + // Request for donation receipts to be issued. + // @since protocol **v21** + donau?: DonationRequestData; }; +export interface DonationRequestData { + // Base URL of the selected Donau + url: string; + + // Year for which the donation receipts are expected. + // Also determines which keys are used to sign the + // blinded donation receipts. + year: number; + + // Array of blinded donation receipts to sign. + // Must NOT be empty (if no donation receipts + // are desired, just leave the entire donau + // argument blank). + budikeypairs: BlindedDonationReceiptKeyPair[]; +} + export interface TestingWaitExchangeStateRequest { exchangeBaseUrl: string; walletKycStatus?: ExchangeWalletKycStatus; @@ -4224,3 +4249,14 @@ export const codecForSetDonauRequest = (): Codec<SetDonauRequest> => .property("donauBaseUrl", codecForString()) .property("taxPayerId", codecForString()) .build("SetDonauRequest"); + +export interface DonauStatementItem { + total: AmountString; + uri: string; + donationStatementSig: EddsaSignatureString; + donauPub: EddsaPublicKeyString; +} + +export interface GetDonauStatementsResponse { + statements: DonauStatementItem[]; +} diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -94,7 +94,6 @@ import { toHexString, TokenEnvelope, TokenIssueBlindSig, - TokenIssuePublicKey, UnblindedDenominationSignature, WireFee, WithdrawalPlanchet, @@ -214,6 +213,8 @@ export interface TalerCryptoInterface { rsaVerify(req: RsaVerificationRequest): Promise<ValidationResult>; + rsaVerifyDirect(req: RsaVerificationRequest): Promise<ValidationResult>; + rsaBlind(req: RsaBlindRequest): Promise<RsaBlindResponse>; signDepositPermission( @@ -543,6 +544,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<DerivedRefreshSessionV2> { throw new Error("Function not implemented."); }, + rsaVerifyDirect: function ( + req: RsaVerificationRequest, + ): Promise<ValidationResult> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -1368,7 +1374,8 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { }, /** - * Unblind a blindly signed value. + * Verify an RSA signature, where the signature + * is over the hash of the message. */ async rsaVerify( tci: TalerCryptoInterfaceR, @@ -1384,6 +1391,22 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { }, /** + * Verify an RSA signature. + */ + async rsaVerifyDirect( + tci: TalerCryptoInterfaceR, + req: RsaVerificationRequest, + ): Promise<ValidationResult> { + return { + valid: rsaVerify( + decodeCrock(req.hm), + decodeCrock(req.sig), + decodeCrock(req.pk), + ), + }; + }, + + /** * Generate updated coins (to store in the database) * and deposit permissions for each given coin. */ diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -35,6 +35,7 @@ import { Amounts, AttentionInfo, BackupProviderTerms, + BlindedUniqueDonationIdentifier, CancellationToken, Codec, CoinEnvelope, @@ -46,6 +47,7 @@ import { DenomSelectionState, DenominationInfo, DenominationPubKey, + DonationReceiptSignature, EddsaPublicKeyString, EddsaSignatureString, ExchangeAuditor, @@ -163,7 +165,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 19; +export const WALLET_DB_MINOR_VERSION = 20; declare const symDbProtocolTimestamp: unique symbol; @@ -1528,6 +1530,14 @@ export interface PurchaseRecord { posConfirmation: string | undefined; + donauOutputIndex?: number; + donauBaseUrl?: string; + donauAmount?: AmountString; + donauTaxIdHash?: string; + donauTaxIdSalt?: string; + donauTaxId?: string; + donauYear?: number; + /** * This purchase was shared with another wallet * that is now supposed to finish the payment. @@ -1580,11 +1590,20 @@ export interface PurchaseRecord { export enum ConfigRecordKey { WalletBackupState = "walletBackupState", CurrencyDefaultsApplied = "currencyDefaultsApplied", - DevMode = "devMode", // Only for testing, do not use! TestLoopTx = "testTxLoop", LastInitInfo = "lastInitInfo", MaterializedTransactionsVersion = "materializedTransactionsVersion", + DonauConfig = "donauConfig", +} + +export interface DonauConfig { + donauBaseUrl: string; + donauTaxId: string; + /** Tax ID hash, salted with donauSalt */ + donauTaxIdHash: string; + /** 32 byte salt, base32crockford encoded */ + donauSalt: string; } /** @@ -1599,7 +1618,8 @@ export type ConfigRecord = | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean } | { key: ConfigRecordKey.TestLoopTx; value: number } | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp } - | { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number }; + | { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number } + | { key: ConfigRecordKey.DonauConfig; value: DonauConfig }; export interface WalletBackupConfState { deviceId: string; @@ -2794,6 +2814,59 @@ export interface ExchangeBaseUrlFixupRecord { replacement: string; } +export enum DonationReceiptStatus { + /** + * Done indicates that the receipt + * has been successfully submitted. + */ + DoneSubmitted = 0x0500_0000, + + /** + * Pending indicates that the + * receipt still needs to be submitted. + */ + Pending = 0x0100_0000, +} + +/** + * Record for donation planchets. + */ +export interface DonationPlanchetRecord { + donauBaseUrl: string; + udiNonce: HashCodeString; + donorTaxIdHash: HashCodeString; + donorHashSalt: string; + donorTaxId: string; + donationYear: number; + proposalId: string; + /** Index of this udi within the selected donation units for the purchase. */ + udiIndex: number; + blindedUdi: BlindedUniqueDonationIdentifier; + /** blinding key secret */ + bks: string; + donationUnitPubHash: HashCodeString; + value: AmountString; +} + +/** + * Record for donation receipts. + */ +export interface DonationReceiptRecord { + status: DonationReceiptStatus; + donauBaseUrl: string; + udiNonce: HashCodeString; + proposalId: string; + donationYear: number; + donationUnitPubHash: HashCodeString; + donationUnitSig: DonationReceiptSignature; + donorTaxIdHash: HashCodeString; + donorHashSalt: string; + donorTaxId: string; + value: AmountString; + /** Index of this udi within the selected donation units for the purchase. */ + udiIndex: number; +} + /** * Schema definition for the IndexedDB * wallet database. @@ -3099,6 +3172,28 @@ export const WalletStoresV1 = { }), }, ), + donationPlanchets: describeStoreV2({ + recordCodec: passthroughCodec<DonationPlanchetRecord>(), + storeName: "donationPlanchets", + keyPath: "udiNonce", + versionAdded: 20, + indexes: { + byProposalId: describeIndex("byProposalId", "proposalId", { + versionAdded: 20, + }), + }, + }), + donationReceipts: describeStoreV2({ + recordCodec: passthroughCodec<DonationReceiptRecord>(), + storeName: "donationReceipts", + keyPath: "udiNonce", + versionAdded: 20, + indexes: { + byStatus: describeIndex("byStatus", "status", { + versionAdded: 20, + }), + }, + }), withdrawalGroups: describeStore( "withdrawalGroups", describeContents<WithdrawalGroupRecord>({ @@ -4100,4 +4195,11 @@ export namespace WalletDbHelpers { source: req.source, }); } + + export async function getConfig<T extends ConfigRecord["key"]>( + tx: WalletDbReadWriteTransaction<["config"]>, + key: T, + ): Promise<Extract<ConfigRecord, { key: T }> | undefined> { + return (await tx.config.get(key)) as any; + } } diff --git a/packages/taler-wallet-core/src/donau.ts b/packages/taler-wallet-core/src/donau.ts @@ -14,12 +14,475 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { EmptyObject, SetDonauRequest } from "@gnu-taler/taler-util"; +/** + * Support for the Taler donation authority (donau). + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * Imports. + */ +import { + Amount, + AmountLike, + Amounts, + AmountString, + BlindedUniqueDonationIdentifier, + createHashContext, + decodeCrock, + DenomKeyType, + DonationReceiptSignature, + DonationUnitKeyGroupRsa, + DonauHttpClient, + DonauKeysResponse, + DonauStatementItem, + DonauUnitPubKey, + EmptyObject, + encodeCrock, + GetDonauStatementsResponse, + getRandomBytes, + HashCodeString, + j2s, + kdfKw, + Logger, + SetDonauRequest, + SignedTokenEnvelope, + stringToBytes, + succeedOrThrow, +} from "@gnu-taler/taler-util"; +import { + ConfigRecordKey, + DonationPlanchetRecord, + DonationReceiptRecord, + DonationReceiptStatus, +} from "./db.js"; import { WalletExecutionContext } from "./index.js"; +/** + * Logger. + */ +const logger = new Logger("donau.ts"); + +/** + * Implementation of the getDonauStatements + * wallet-core request. + */ +export async function handleGetDonauStatements( + wex: WalletExecutionContext, + _req: EmptyObject, +): Promise<GetDonauStatementsResponse> { + const statements: DonauStatementItem[] = []; + const pendingReceipts = await wex.db.runAllStoresReadOnlyTx( + {}, + async (tx) => { + return await tx.donationReceipts.indexes.byStatus.getAll( + DonationReceiptStatus.Pending, + ); + }, + ); + + const donauUrlSet = new Set<string>( + pendingReceipts.map((x) => x.donauBaseUrl), + ); + const donauUrls = [...donauUrlSet]; + + for (const donauUrl of donauUrls) { + const donauClient = new DonauHttpClient(donauUrl); + // Map from `${taxIdHash}-${year}` to receipts (in the same hash/year/baseUrl) + const buckets: Map<string, DonationReceiptRecord[]> = new Map(); + for (const receipt of pendingReceipts) { + if (receipt.donauBaseUrl != donauUrl) { + continue; + } + const key = `${receipt.donorTaxIdHash}-${receipt.donationYear}`; + let bucket: DonationReceiptRecord[] | undefined; + bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(receipt); + } + for (const batch of buckets.values()) { + logger.info(`submitting donation receipt`); + const r0 = batch[0]; + if (!r0) { + continue; + } + succeedOrThrow( + await donauClient.postBatchSubmit({ + body: { + h_donor_tax_id: r0.donorTaxIdHash, + donation_receipts: batch.map((x) => ({ + donation_unit_sig: x.donationUnitSig, + h_donation_unit_pub: x.donationUnitPubHash, + nonce: x.udiNonce, + })), + donation_year: r0.donationYear, + }, + }), + ); + + const stmt = succeedOrThrow( + await donauClient.getDonationStatement({ + taxIdHash: r0.donorTaxIdHash, + year: r0.donationYear, + }), + ); + const parsedDonauUrl = new URL(r0.donauBaseUrl); + const proto = parsedDonauUrl.protocol == "http:" ? "donau+http" : "donau"; + const taxIdEnc = encodeURIComponent(r0.donorTaxId); + statements.push({ + donationStatementSig: stmt.donation_statement_sig, + donauPub: stmt.donau_pub, + total: stmt.total, + // FIXME: Generate this using some helper + // FIXME: What about a donau not at the root path? + uri: `${proto}://${parsedDonauUrl.host}/${r0.donationYear}/${taxIdEnc}?total=${stmt.total}&sig=ED25519:${stmt.donation_statement_sig}`, + }); + } + } + + return { + statements, + }; +} + +/** + * Implementation of the setDonau + * wallet-core request. + */ export async function handleSetDonau( wex: WalletExecutionContext, req: SetDonauRequest, ): Promise<EmptyObject> { - throw Error("not implemented"); + // FIXME: This should be idempotent, do not re-salt + // for same taxpayer ID. + const salt = getRandomBytes(32); + // FIXME: Where is the salted hashing for this specified? + const saltedId = kdfKw({ + outputLength: 64, + ikm: stringToBytes(req.taxPayerId), + salt: salt, + info: stringToBytes("tax-payer-id-hash"), + }); + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + await tx.config.put({ + key: ConfigRecordKey.DonauConfig, + value: { + donauBaseUrl: req.donauBaseUrl, + donauTaxId: req.taxPayerId, + donauSalt: encodeCrock(salt), + donauTaxIdHash: encodeCrock(saltedId), + }, + }); + }); + return {}; +} + +/** + * Info about a donation unit key from the donau. + */ +interface CandidateDonationUnit { + value: AmountString; + unitHash: HashCodeString; + unitKey: DonauUnitPubKey; +} + +/** + * Filter out applicable donation units + * from the donau keys response. + */ +async function getCandidateDonationUnits( + resp: DonauKeysResponse, + currentYear: number, + amount: AmountLike, +): Promise<CandidateDonationUnit[]> { + const candidates: CandidateDonationUnit[] = []; + logger.info(`finding donau candidates for current year ${currentYear}`); + for (const g of resp.donation_units) { + if (Amounts.cmp(g.value, amount) > 0) { + continue; + } + if (g.donation_unit_pub.cipher !== "RSA") { + continue; + } + if (g.year != currentYear) { + continue; + } + if (g.lost) { + continue; + } + candidates.push({ + unitHash: g.donation_unit_pub.pub_key_hash, + unitKey: { + cipher: DenomKeyType.Rsa, + age_mask: 0, + rsa_public_key: g.donation_unit_pub.rsa_public_key, + }, + value: g.value, + }); + } + candidates.sort((a, b) => Amounts.cmp(b.value, a.value)); + return candidates; +} + +/** + * Hash the unique donation identifier (UDI) nonce (udiNonce) + * and tax id hash to obtain the UDI hash (udiHash). + */ +function hashUdi(udiNonce: Uint8Array, taxIdHash: Uint8Array): Uint8Array { + const hc = createHashContext(); + hc.update(taxIdHash); + // Do this to be compatible with the (currently bad) donau crypto + hc.update(udiNonce); + return hc.finish(); +} + +/** + * Generate donau planchets for a purchase (identified by the proposal ID). + * + * Preconditions: + * - The purchase must have its choiceIndex and rec.donau* properties set. + */ +export async function generateDonauPlanchets( + wex: WalletExecutionContext, + proposalId: string, +): Promise<void> { + const res = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + const rec = await tx.purchases.get(proposalId); + if (!rec) { + return undefined; + } + if (!rec.donauBaseUrl) { + return undefined; + } + if (!rec.donauAmount) { + throw Error("db consistency error: donau amout not set"); + } + if (typeof rec.donauYear !== "number") { + throw Error("db consistency error: donau year not a number"); + } + if (typeof rec.choiceIndex !== "number") { + throw Error("choice required for donau"); + } + if (rec.donauTaxIdHash == null) { + throw Error("donau tax id hash required"); + } + if (rec.donauTaxId == null) { + throw Error("donau tax id required"); + } + if (rec.donauTaxIdSalt == null) { + throw Error("donau tax id salt required"); + } + return { + donauBaseUrl: rec.donauBaseUrl, + donauAmount: rec.donauAmount, + donauYear: rec.donauYear, + donauOutputIndex: rec.donauOutputIndex, + taxIdHash: rec.donauTaxIdHash, + taxIdSalt: rec.donauTaxIdSalt, + taxId: rec.donauTaxId, + choiceIndex: rec.choiceIndex, + purchaseRec: rec, + }; + }); + + if (!res) { + return; + } + + logger.info(`creating budi for ${j2s(res)}`); + + const client = new DonauHttpClient(res.donauBaseUrl); + + const keysResp = succeedOrThrow(await client.getKeys()); + + const candidates = await getCandidateDonationUnits( + keysResp, + res.donauYear, + res.donauAmount, + ); + + logger.info(`created ${candidates.length} donau candidates`); + + let remaining = Amount.from(res.donauAmount); + const selection: CandidateDonationUnit[] = []; + + let i = 0; + while (i < candidates.length) { + if (remaining.isZero()) { + break; + } + const cand = candidates[i]; + if (Amounts.cmp(remaining, cand.value) >= 0) { + selection.push(cand); + remaining = remaining.sub(cand.value); + } else { + i++; + } + } + + const donauPlanchets: DonationPlanchetRecord[] = []; + + for (let udiIndex = 0; udiIndex < selection.length; udiIndex++) { + const sel = selection[udiIndex]; + + const udiNonce = getRandomBytes(32); + + let blindedUdi: BlindedUniqueDonationIdentifier; + const bks = getRandomBytes(32); + + switch (sel.unitKey.cipher) { + case DenomKeyType.Rsa: { + const hm = encodeCrock(hashUdi(udiNonce, decodeCrock(res.taxIdHash))); + logger.info(`pub at blinding: ${sel.unitKey.rsa_public_key}`); + logger.info(`bks at blinding: ${encodeCrock(bks)}`); + logger.info(`hm at blinding: ${hm}`); + const blindRes = await wex.cryptoApi.rsaBlind({ + pub: sel.unitKey.rsa_public_key, + hm, + bks: encodeCrock(bks), + }); + blindedUdi = { + cipher: "RSA", + rsa_blinded_identifier: blindRes.blinded, + }; + break; + } + default: + throw Error("key type not supported"); + } + + donauPlanchets.push({ + donauBaseUrl: res.donauBaseUrl, + donationUnitPubHash: sel.unitHash, + donationYear: res.donauYear, + proposalId, + udiIndex, + donorTaxIdHash: res.taxIdHash, + donorHashSalt: res.taxIdSalt, + donorTaxId: res.taxId, + udiNonce: encodeCrock(udiNonce), + blindedUdi, + bks: encodeCrock(bks), + value: sel.value, + }); + } + + logger.info(`created ${donauPlanchets.length} donau planchets`); + + logger.trace(`donau planchets: ${j2s(donauPlanchets)}`); + + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const rec = await tx.purchases.get(proposalId); + if (!rec) { + return undefined; + } + const existingPlanchets = + await tx.donationPlanchets.indexes.byProposalId.getAllKeys([ + rec.proposalId, + ]); + if (existingPlanchets.length > 0) { + return; + } + for (const dp of donauPlanchets) { + await tx.donationPlanchets.put(dp); + } + }); +} + +/** + * Accept blinded signatures from the donau. + * + * Unblind them and store them in the wallet database. + */ +export async function acceptDonauBlindSigs( + wex: WalletExecutionContext, + donauBaseUrl: string, + donauPlanchets: DonationPlanchetRecord[], + donauBlindedSigs: SignedTokenEnvelope[], +): Promise<void> { + if (donauPlanchets.length != donauBlindedSigs.length) { + throw Error(); + } + + const client = new DonauHttpClient(donauBaseUrl); + + // FIXME: Take this from the database instead of querying each time. + const keysResp = succeedOrThrow(await client.getKeys()); + + const sigs: DonationReceiptSignature[] = []; + + for (let i = 0; i < donauBlindedSigs.length; i++) { + const myPlanchet = donauPlanchets[i]; + const myBlindSig = donauBlindedSigs[i].blind_sig; + let unitKey: DonationUnitKeyGroupRsa | undefined; + for (let j = 0; j < keysResp.donation_units.length; j++) { + const candidate = keysResp.donation_units[j]; + if ( + candidate.donation_unit_pub.cipher === "RSA" && + candidate.donation_unit_pub.pub_key_hash === + myPlanchet.donationUnitPubHash + ) { + unitKey = candidate as DonationUnitKeyGroupRsa; + break; + } + } + if (!unitKey) { + throw Error("donation unit key not found"); + } + logger.info(`found unit key ${j2s(unitKey)}`); + if (myBlindSig.cipher !== DenomKeyType.Rsa) { + throw Error("only RSA supported"); + } + const unblindSig = await wex.cryptoApi.rsaUnblind({ + pk: unitKey.donation_unit_pub.rsa_public_key, + bk: myPlanchet.bks, + blindedSig: myBlindSig.blinded_rsa_signature, + }); + const udiHash = hashUdi( + decodeCrock(myPlanchet.udiNonce), + decodeCrock(myPlanchet.donorTaxIdHash), + ); + const verifyRes = await wex.cryptoApi.rsaVerifyDirect({ + hm: encodeCrock(udiHash), + pk: unitKey.donation_unit_pub.rsa_public_key, + sig: unblindSig.sig, + }); + if (!verifyRes.valid) { + throw Error("invalid donau signature"); + } + sigs.push({ + cipher: "RSA", + rsa_signature: unblindSig.sig, + }); + } + + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + for (let i = 0; i < donauBlindedSigs.length; i++) { + const myPlanchet = donauPlanchets[i]; + const existingReceipt = await tx.donationReceipts.get( + myPlanchet.udiNonce, + ); + if (existingReceipt) { + continue; + } + await tx.donationReceipts.put({ + donationUnitSig: sigs[i], + donationUnitPubHash: myPlanchet.donationUnitPubHash, + donauBaseUrl: myPlanchet.donauBaseUrl, + proposalId: myPlanchet.proposalId, + udiNonce: myPlanchet.udiNonce, + status: DonationReceiptStatus.Pending, + donorHashSalt: myPlanchet.donorHashSalt, + donorTaxId: myPlanchet.donorTaxId, + donorTaxIdHash: myPlanchet.donorTaxIdHash, + donationYear: myPlanchet.donationYear, + udiIndex: myPlanchet.udiIndex, + value: myPlanchet.value, + }); + } + }); } diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -32,6 +32,7 @@ import { Amounts, AmountString, assertUnreachable, + BlindedDonationReceiptKeyPair, checkDbInvariant, checkLogicInvariant, CheckPayTemplateReponse, @@ -146,8 +147,10 @@ import { import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js"; import { CoinRecord, + ConfigRecordKey, DbCoinSelection, DenominationRecord, + DonationPlanchetRecord, PurchaseRecord, PurchaseStatus, RefundGroupRecord, @@ -163,10 +166,12 @@ import { TokenRecord, WalletDbAllStoresReadOnlyTransaction, WalletDbAllStoresReadWriteTransaction, + WalletDbHelpers, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, WalletDbStoresArr, } from "./db.js"; +import { generateDonauPlanchets, acceptDonauBlindSigs } from "./donau.js"; import { getScopeForAllCoins, getScopeForAllExchanges } from "./exchanges.js"; import { calculateRefreshOutput, @@ -2766,11 +2771,17 @@ async function calculateDefaultChoice( */ export async function confirmPay( wex: WalletExecutionContext, - transactionId: string, - sessionIdOverride?: string, - forcedCoinSel?: ForcedCoinSel, - choiceIndex?: number, + args: { + transactionId: string; + sessionIdOverride?: string; + forcedCoinSel?: ForcedCoinSel; + choiceIndex?: number; + useDonau?: boolean; + }, ): Promise<ConfirmPayResult> { + const { transactionId, sessionIdOverride, forcedCoinSel } = args; + let { choiceIndex } = args; + const parsedTx = parseTransactionIdentifier(transactionId); if (parsedTx?.tag !== TransactionType.Payment) { throw Error("expected payment transaction ID"); @@ -2954,6 +2965,49 @@ export async function confirmPay( p.download.currency = Amounts.currencyOf(amount); } + const confRes = await WalletDbHelpers.getConfig( + tx, + ConfigRecordKey.DonauConfig, + ); + + logger.info( + `dona conf: ${j2s(confRes)}, useDonau: ${ + args.useDonau + }, choiceIndex: ${choiceIndex}, oidx=${p.donauOutputIndex}, ctVersion=${ + contractTerms.version + }`, + ); + + if ( + confRes != null && + args.useDonau && + choiceIndex != null && + contractTerms.version === MerchantContractVersion.V1 && + p.donauOutputIndex == null + ) { + const choice = contractTerms.choices[choiceIndex]; + + logger.info(`have outputs: ${j2s(choice.outputs)}`); + + for (let j = 0; j < choice.outputs.length; j++) { + const out = choice.outputs[j]; + if ( + out.type === MerchantContractOutputType.TaxReceipt && + out.donau_urls.includes(confRes.value.donauBaseUrl) + ) { + p.donauOutputIndex = j; + p.donauBaseUrl = confRes.value.donauBaseUrl; + p.donauYear = new Date().getFullYear(); + p.donauAmount = + out.amount ?? contractTerms.choices[choiceIndex].amount; + p.donauTaxIdHash = confRes.value.donauTaxIdHash; + p.donauTaxId = confRes.value.donauTaxId; + p.donauTaxIdSalt = confRes.value.donauSalt; + break; + } + } + } + const oldTxState = computePayMerchantTransactionState(p); const oldStId = p.purchaseStatus; switch (p.purchaseStatus) { @@ -3016,7 +3070,8 @@ export async function confirmPay( ) { const choice = contractTerms.choices[choiceIndex]; for (let j = 0; j < choice.outputs.length; j++) { - switch (choice.outputs[j].type) { + const tok = choice.outputs[j]; + switch (tok.type) { case MerchantContractOutputType.Token: { await generateSlate( wex, @@ -3029,7 +3084,8 @@ export async function confirmPay( break; } case MerchantContractOutputType.TaxReceipt: - logger.warn(`tax receipt output not yet supported`); + // FIXME: What if we have multiple? + await generateDonauPlanchets(wex, proposalId); break; } } @@ -3301,11 +3357,14 @@ async function processPurchasePay( ); let slates: SlateRecord[] | undefined = undefined; + let donauPlanchets: DonationPlanchetRecord[] | undefined = undefined; let wallet_data: PayWalletData | undefined = undefined; if ( download.contractTerms.version === MerchantContractVersion.V1 && purchase.choiceIndex !== undefined ) { + logger.info("assembling tokens"); + logger.info(`donau output index: ${purchase.donauOutputIndex}`); const index = purchase.choiceIndex; slates = []; wallet_data = { choice_index: index, tokens_evs: [] }; @@ -3325,6 +3384,38 @@ async function processPurchasePay( }); }, ); + if (purchase.donauOutputIndex != null) { + if (purchase.donauBaseUrl == null || purchase.donauYear == null) { + throw Error("incomplete donau info in DB"); + } + const budikeypairs: BlindedDonationReceiptKeyPair[] = []; + // FIXME: Merge with transaction above + const res = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + const recs = + await tx.donationPlanchets.indexes.byProposalId.getAll(proposalId); + for (const rec of recs) { + budikeypairs.push({ + blinded_udi: rec.blindedUdi, + h_donation_unit_pub: rec.donationUnitPubHash, + }); + } + if (recs.length > 0) { + return { + donauPlanchets: recs, + }; + } + return undefined; + }); + if (res?.donauPlanchets) { + donauPlanchets = res.donauPlanchets; + } + wallet_data.donau = { + url: purchase.donauBaseUrl, + year: purchase.donauYear, + budikeypairs, + }; + logger.info(`sending donau data: ${wallet_data.donau}`); + } // Note that we may have fewer slates that output tokens, // as there are other output types (e.g. slates). } @@ -3461,20 +3552,35 @@ async function processPurchasePay( throw Error("merchant payment signature invalid"); } - let tokenSigs; + // Here we make the assumption that slate signatures always + // come before donau signatures. + // But the merchant API should be improved + // to separate them. + + /** Start index of processed outpok tokens. */ + let outTokOffset = 0; + + logger.info(`have slates: ${slates?.length}`); + + let tokenSigs: SignedTokenEnvelope[] | undefined; if (payInfo.slateTokenSigs) { tokenSigs = payInfo.slateTokenSigs; - } else { - const slatesLen = slates?.length ?? 0; - const sigsLen = merchantResp.token_sigs?.length ?? 0; + } else if ( + slates && + slates.length > 0 && + merchantResp.token_sigs && + merchantResp.token_sigs.length > 0 + ) { + const slatesLen = slates.length; + const sigsLen = merchantResp.token_sigs.length; logger.trace(`received ${sigsLen} token signatures from merchant`); - if (slatesLen !== sigsLen) { + if (slatesLen > sigsLen) { throw Error( `merchant returned mismatching number of token signatures (${slatesLen} vs ${sigsLen})`, ); - } else if (merchantResp.token_sigs) { - tokenSigs = merchantResp.token_sigs; } + tokenSigs = merchantResp.token_sigs.slice(0, slatesLen); + outTokOffset = slatesLen; } if (tokenSigs) { @@ -3501,6 +3607,29 @@ async function processPurchasePay( } } + if (donauPlanchets && merchantResp.token_sigs) { + const donauBlindedSigs = merchantResp.token_sigs.slice( + outTokOffset, + outTokOffset + donauPlanchets.length, + ); + if (donauPlanchets.length != donauBlindedSigs.length) { + throw Error( + `wrong number of donau signatures (planchets ${donauPlanchets.length} vs sigs ${donauBlindedSigs.length}`, + ); + } + logger.info(`got ${donauPlanchets.length} donau sigs`); + const donauUrl = purchase.donauBaseUrl; + if (!donauUrl) { + throw Error("bad db: no donau URL"); + } + await acceptDonauBlindSigs( + wex, + donauUrl, + donauPlanchets, + donauBlindedSigs, + ); + } + // cleanup token inputs if (payInfo.payTokenSelection?.tokenPubs) { await cleanupUsedTokens(wex, payInfo.payTokenSelection.tokenPubs); diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts @@ -300,11 +300,9 @@ async function makePayment( throw Error("payment not possible"); } - const confirmPayResult = await confirmPay( - wex, - preparePayResult.transactionId, - undefined, - ); + const confirmPayResult = await confirmPay(wex, { + transactionId: preparePayResult.transactionId, + }); logger.trace("confirmPayResult", confirmPayResult); @@ -954,12 +952,10 @@ export async function testPay( if (result.status !== PreparePayResultType.PaymentPossible) { throw Error(`unexpected prepare pay status: ${result.status}`); } - const r = await confirmPay( - wex, - result.transactionId, - undefined, - args.forcedCoinSel, - ); + const r = await confirmPay(wex, { + transactionId: result.transactionId, + forcedCoinSel: args.forcedCoinSel, + }); if (r.type != ConfirmPayResultType.Done) { throw Error("payment not done"); } diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -90,6 +90,7 @@ import { GetDepositWireTypesForCurrencyResponse, GetDepositWireTypesRequest, GetDepositWireTypesResponse, + GetDonauStatementsResponse, GetExchangeDetailedInfoRequest, GetExchangeEntryByUrlRequest, GetExchangeEntryByUrlResponse, @@ -288,6 +289,7 @@ export enum WalletApiOperation { // Donau SetDonau = "setDonau", + GetDonauStatements = "getDonauStatements", // Stored backups @@ -397,6 +399,12 @@ export type SetDonauOp = { response: EmptyObject; }; +export type GetDonauStatementsOp = { + op: WalletApiOperation.GetDonauStatements; + request: EmptyObject; + response: GetDonauStatementsResponse; +}; + // group: Basic Wallet Information /** @@ -1549,6 +1557,7 @@ export type WalletOperations = { [WalletApiOperation.HintApplicationResumed]: HintApplicationResumedOp; [WalletApiOperation.CompleteExchangeBaseUrl]: CompleteExchangeBaseUrlOp; [WalletApiOperation.SetDonau]: SetDonauOp; + [WalletApiOperation.GetDonauStatements]: GetDonauStatementsOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -301,7 +301,7 @@ import { generateDepositGroupTxId, } from "./deposits.js"; import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; -import { handleSetDonau } from "./donau.js"; +import { handleGetDonauStatements, handleSetDonau } from "./donau.js"; import { ReadyExchangeSummary, acceptExchangeTermsOfService, @@ -1258,13 +1258,13 @@ async function handleConfirmPay( wex: WalletExecutionContext, req: ConfirmPayRequest, ): Promise<ConfirmPayResult> { - return await confirmPay( - wex, - req.transactionId, - req.sessionId, - undefined, - req.choiceIndex, - ); + return await confirmPay(wex, { + transactionId: req.transactionId, + choiceIndex: req.choiceIndex, + forcedCoinSel: undefined, + sessionIdOverride: req.sessionId, + useDonau: req.useDonau, + }); } async function handleAbortTransaction( @@ -1867,6 +1867,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForAny(), handler: handleTestingWaitExchangeState, }, + [WalletApiOperation.GetDonauStatements]: { + codec: codecForEmptyObject(), + handler: handleGetDonauStatements, + }, [WalletApiOperation.SetDonau]: { codec: codecForSetDonauRequest(), handler: handleSetDonau,