merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit 97439d8f6b5476ef6496a1ebe7a72fb1b642632c
parent 8e1875a48837c06ce1085fb38ecfab1037b1b6b0
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Mon,  3 Aug 2026 22:04:01 +0200

dd98

Diffstat:
Msrc/backend/meson.build | 9+++++++++
Asrc/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h | 44++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_dispatcher.c | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_fountains.c | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_fountains.h | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_get-fountain-info.c | 449+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_get-fountain-info.h | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h | 44++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_get-private-fountains.c | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_get-private-fountains.h | 44++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h | 44++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-fountain-withdraw.c | 1284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-fountain-withdraw.h | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-private-fountains.c | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-private-fountains.h | 44++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_post-private-orders.c | 476+++++++------------------------------------------------------------------------
Asrc/backend/taler-merchant-httpd_token-keys.c | 833+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_token-keys.h | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/delete_fountain.c | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/do_fountain_withdraw.c | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/do_fountain_withdraw.sql | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/do_insert_fountain.c | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/do_insert_fountain.sql | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/do_update_fountain.c | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/do_update_fountain.sql | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/gc.sql | 18++++++++++++++++++
Asrc/backenddb/get_fountain.c | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/get_fountain_by_secret.c | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/get_fountain_withdraw.c | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/get_token_family_key.c | 37+++++++++++++++++++++++++++++++++++++
Asrc/backenddb/insert_fountain_withdraw_sig.c | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/insert_issued_token.c | 4+++-
Asrc/backenddb/iterate_fountain_grants.c | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/iterate_fountains.c | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/meson.build | 10++++++++++
Msrc/backenddb/sql-schema/merchant-0048.sql | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/backenddb/sql-schema/meson.build | 3+++
Msrc/backenddb/test_merchantdb.c | 355++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/backenddb/test_order_sequence_migrations.py | 28++++++++++++++++++++++++++++
Msrc/include/merchant-database/all.h | 8++++++++
Asrc/include/merchant-database/delete_fountain.h | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/do_fountain_withdraw.h | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/do_insert_fountain.h | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/do_update_fountain.h | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/get_fountain.h | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/get_fountain_by_secret.h | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/get_fountain_withdraw.h | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/merchant-database/get_token_family_key.h | 19+++++++++++++++++++
Asrc/include/merchant-database/insert_fountain_withdraw_sig.h | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/merchant-database/insert_issued_token.h | 3++-
Asrc/include/merchant-database/iterate_fountain_grants.h | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/iterate_fountains.h | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/taler/merchant/delete-private-fountains-FOUNTAIN_ID.h | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/taler/merchant/get-fountain-info.h | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/taler/merchant/get-private-fountains-FOUNTAIN_ID.h | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/taler/merchant/get-private-fountains.h | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler/merchant/meson.build | 9+++++++--
Asrc/include/taler/merchant/patch-private-fountains-FOUNTAIN_ID.h | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/taler/merchant/post-fountain-withdraw.h | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/taler/merchant/post-private-fountains.h | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler/taler_merchant_service.h | 7+++++++
Msrc/include/taler/taler_merchant_testing_lib.h | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/include/taler/taler_merchant_util.h | 17+++++++++++++++++
Msrc/lib/merchant_api_common.h | 8++++++++
Asrc/lib/merchant_api_delete-private-fountains-FOUNTAIN_ID.c | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_get-fountain-info.c | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_get-private-fountains-FOUNTAIN_ID.c | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_get-private-fountains.c | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_patch-private-fountains-FOUNTAIN_ID.c | 303+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_post-fountain-withdraw.c | 402+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_post-private-fountains.c | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/meson.build | 22++++++++++++++++++++++
Asrc/lib/test_fountain_parsers.c | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/meson.build | 3+++
Msrc/testing/test_merchant_api.c | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/testing/test_merchant_fountains.sh | 879+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/testing/testing_api_cmd_fountain_withdraw.c | 616+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/testing/testing_api_cmd_post_fountains.c | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util/token_family_parse.c | 21+++++++++++++++++++++
82 files changed, 12318 insertions(+), 457 deletions(-)

diff --git a/src/backend/meson.build b/src/backend/meson.build @@ -77,6 +77,9 @@ taler_merchant_httpd_SOURCES = [ 'taler-merchant-httpd_get-exchanges.c', 'taler-merchant-httpd_get-templates-TEMPLATE_ID.c', 'taler-merchant-httpd_helper.c', + 'taler-merchant-httpd_token-keys.c', + 'taler-merchant-httpd_fountains.c', + 'taler-merchant-httpd_get-fountain-info.c', 'taler-merchant-httpd_mhd.c', 'taler-merchant-httpd_get-terms.c', 'taler-merchant-httpd_mfa.c', @@ -88,6 +91,7 @@ taler_merchant_httpd_SOURCES = [ 'taler-merchant-httpd_delete-private-tokens-SERIAL.c', 'taler-merchant-httpd_delete-private-products-PRODUCT_ID.c', 'taler-merchant-httpd_delete-private-orders-ORDER_ID.c', + 'taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c', 'taler-merchant-httpd_delete-private-otp-devices-DEVICE_ID.c', 'taler-merchant-httpd_delete-private-templates-TEMPLATE_ID.c', 'taler-merchant-httpd_delete-private-tokenfamilies-TOKEN_FAMILY_SLUG.c', @@ -109,6 +113,8 @@ taler_merchant_httpd_SOURCES = [ 'taler-merchant-httpd_get-private-products-PRODUCT_ID.c', 'taler-merchant-httpd_get-private-orders.c', 'taler-merchant-httpd_get-private-orders-ORDER_ID.c', + 'taler-merchant-httpd_get-private-fountains.c', + 'taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c', 'taler-merchant-httpd_get-private-otp-devices.c', 'taler-merchant-httpd_get-private-otp-devices-DEVICE_ID.c', 'taler-merchant-httpd_get-private-incoming.c', @@ -125,6 +131,7 @@ taler_merchant_httpd_SOURCES = [ 'taler-merchant-httpd_patch-private-units-UNIT.c', 'taler-merchant-httpd_patch-management-instances-INSTANCE.c', 'taler-merchant-httpd_patch-private-orders-ORDER_ID-forget.c', + 'taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c', 'taler-merchant-httpd_patch-private-otp-devices-DEVICE_ID.c', 'taler-merchant-httpd_patch-private-products-PRODUCT_ID.c', 'taler-merchant-httpd_patch-private-templates-TEMPLATE_ID.c', @@ -141,6 +148,7 @@ taler_merchant_httpd_SOURCES = [ 'taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c', 'taler-merchant-httpd_post-private-orders.c', 'taler-merchant-httpd_post-private-products.c', + 'taler-merchant-httpd_post-private-fountains.c', 'taler-merchant-httpd_post-private-otp-devices.c', 'taler-merchant-httpd_post-private-products-PRODUCT_ID-lock.c', 'taler-merchant-httpd_post-private-templates.c', @@ -153,6 +161,7 @@ taler_merchant_httpd_SOURCES = [ 'taler-merchant-httpd_post-challenge-ID-confirm.c', 'taler-merchant-httpd_post-orders-ORDER_ID-abort.c', 'taler-merchant-httpd_post-orders-ORDER_ID-claim.c', + 'taler-merchant-httpd_post-fountain-withdraw.c', 'taler-merchant-httpd_post-orders-ORDER_ID-pay.c', 'taler-merchant-httpd_post-orders-ORDER_ID-paid.c', 'taler-merchant-httpd_post-orders-ORDER_ID-refund.c', diff --git a/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c b/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c @@ -0,0 +1,70 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c + * @brief implementing DELETE /private/fountains/$FOUNTAIN_ID request handling + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h" +#include <taler/taler_json_lib.h> +#include "merchant-database/delete_fountain.h" + + +enum MHD_Result +TMH_private_delete_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_assert (NULL != mi); + GNUNET_assert (NULL != hc->infix); + qs = TALER_MERCHANTDB_delete_fountain (TMH_db, + mi->settings.id, + hc->infix); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + case GNUNET_DB_STATUS_SOFT_ERROR: + GNUNET_break (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "delete_fountain"); + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_FOUNTAIN_UNKNOWN, + hc->infix); + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + return TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); + } + GNUNET_assert (0); + return MHD_NO; +} + + +/* end of taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c */ diff --git a/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h b/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h @@ -0,0 +1,44 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h + * @brief implementing DELETE /private/fountains/$FOUNTAIN_ID request handling + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H +#define TALER_MERCHANT_HTTPD_DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H + +#include "taler-merchant-httpd.h" + + +/** + * Handle a DELETE "/private/fountains/$FOUNTAIN_ID" request. + * + * @param rh context of the handler + * @param connection the MHD connection to handle + * @param[in,out] hc context with further information about the request + * @return MHD result code + */ +enum MHD_Result +TMH_private_delete_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +#endif diff --git a/src/backend/taler-merchant-httpd_dispatcher.c b/src/backend/taler-merchant-httpd_dispatcher.c @@ -35,6 +35,7 @@ #include "taler-merchant-httpd_delete-private-tokens-SERIAL.h" #include "taler-merchant-httpd_delete-private-products-PRODUCT_ID.h" #include "taler-merchant-httpd_delete-private-orders-ORDER_ID.h" +#include "taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h" #include "taler-merchant-httpd_delete-private-otp-devices-DEVICE_ID.h" #include "taler-merchant-httpd_delete-private-templates-TEMPLATE_ID.h" #include "taler-merchant-httpd_delete-private-tokenfamilies-TOKEN_FAMILY_SLUG.h" @@ -58,6 +59,8 @@ #include "taler-merchant-httpd_get-private-orders.h" #include "taler-merchant-httpd_get-private-orders-ORDER_ID.h" #include "taler-merchant-httpd_get-private-otp-devices.h" +#include "taler-merchant-httpd_get-private-fountains.h" +#include "taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h" #include "taler-merchant-httpd_get-private-otp-devices-DEVICE_ID.h" #include "taler-merchant-httpd_get-private-statistics-amount-SLUG.h" #include "taler-merchant-httpd_get-private-statistics-counter-SLUG.h" @@ -74,6 +77,7 @@ #include "taler-merchant-httpd_patch-private-units-UNIT.h" #include "taler-merchant-httpd_patch-management-instances-INSTANCE.h" #include "taler-merchant-httpd_patch-private-orders-ORDER_ID-forget.h" +#include "taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h" #include "taler-merchant-httpd_patch-private-otp-devices-DEVICE_ID.h" #include "taler-merchant-httpd_patch-private-products-PRODUCT_ID.h" #include "taler-merchant-httpd_patch-private-templates-TEMPLATE_ID.h" @@ -85,6 +89,7 @@ #include "taler-merchant-httpd_post-management-instances.h" #include "taler-merchant-httpd_post-management-instances-INSTANCE-auth.h" #include "taler-merchant-httpd_post-private-token.h" +#include "taler-merchant-httpd_post-private-fountains.h" #include "taler-merchant-httpd_post-private-otp-devices.h" #include "taler-merchant-httpd_post-private-orders.h" #include "taler-merchant-httpd_post-private-orders-ORDER_ID-collect.h" @@ -103,6 +108,8 @@ #include "taler-merchant-httpd_post-orders-ORDER_ID-abort.h" #include "taler-merchant-httpd_post-orders-ORDER_ID-claim.h" #include "taler-merchant-httpd_post-orders-ORDER_ID-paid.h" +#include "taler-merchant-httpd_get-fountain-info.h" +#include "taler-merchant-httpd_post-fountain-withdraw.h" #include "taler-merchant-httpd_post-orders-ORDER_ID-pay.h" #include "taler-merchant-httpd_post-orders-ORDER_ID-unclaim.h" #include "taler-merchant-httpd_post-templates-TEMPLATE_ID.h" @@ -652,6 +659,44 @@ determine_handler_group (const char **urlp, .have_id_segment = true, .handler = &TMH_private_get_incoming_ID }, + /* POST /fountains: */ + { + .url_prefix = "/fountains", + .permission = "fountains-write", + .method = MHD_HTTP_METHOD_POST, + .handler = &TMH_private_post_fountains + }, + /* GET /fountains: */ + { + .url_prefix = "/fountains", + .permission = "fountains-read", + .method = MHD_HTTP_METHOD_GET, + .handler = &TMH_private_get_fountains + }, + /* GET /fountains/$ID: */ + { + .url_prefix = "/fountains/", + .permission = "fountains-read", + .method = MHD_HTTP_METHOD_GET, + .have_id_segment = true, + .handler = &TMH_private_get_fountains_FOUNTAIN_ID + }, + /* PATCH /fountains/$ID: */ + { + .url_prefix = "/fountains/", + .permission = "fountains-write", + .method = MHD_HTTP_METHOD_PATCH, + .have_id_segment = true, + .handler = &TMH_private_patch_fountains_FOUNTAIN_ID + }, + /* DELETE /fountains/$ID: */ + { + .url_prefix = "/fountains/", + .permission = "fountains-write", + .method = MHD_HTTP_METHOD_DELETE, + .have_id_segment = true, + .handler = &TMH_private_delete_fountains_FOUNTAIN_ID + }, /* POST /otp-devices: */ { .url_prefix = "/otp-devices", @@ -1123,6 +1168,21 @@ determine_handler_group (const char **urlp, .default_only = true, .handler = &MH_handler_config }, + /* GET /fountain/info (DD 98): */ + { + .url_prefix = "/fountain/", + .url_suffix = "info", + .method = MHD_HTTP_METHOD_GET, + .handler = &TMH_get_fountain_info + }, + /* POST /fountain/withdraw (DD 98): */ + { + .url_prefix = "/fountain/", + .url_suffix = "withdraw", + .method = MHD_HTTP_METHOD_POST, + .max_upload = 1024 * 1024, + .handler = &TMH_post_fountain_withdraw + }, { .url_prefix = "/exchanges", .method = MHD_HTTP_METHOD_GET, diff --git a/src/backend/taler-merchant-httpd_fountains.c b/src/backend/taler-merchant-httpd_fountains.c @@ -0,0 +1,150 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_fountains.c + * @brief shared validation of private fountain grants + */ +#include "platform.h" +#include "taler-merchant-httpd_fountains.h" +#include "taler-merchant-httpd_token-keys.h" +#include <taler/taler_json_lib.h> + + +/** + * Parse one fountain grant from @a jgrant. + * + * @param connection connection to report errors on + * @param jgrant JSON object to parse + * @param[out] grant set to the parsed grant + * @return #GNUNET_OK on success, #GNUNET_NO if an error was + * already reported, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +parse_fountain_grant (struct MHD_Connection *connection, + const json_t *jgrant, + struct TALER_MERCHANTDB_FountainGrant *grant) +{ + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("token_family_slug", + &grant->token_family_slug), + GNUNET_JSON_spec_uint64 ("tokens_per_period_limit", + &grant->tokens_per_period_limit), + GNUNET_JSON_spec_uint64 ("tokens_per_period_stash", + &grant->tokens_per_period_stash), + GNUNET_JSON_spec_uint32 ("key_window_size", + &grant->key_window_size), + GNUNET_JSON_spec_end () + }; + enum GNUNET_GenericReturnValue res; + + res = TALER_MHD_parse_json_data (connection, + jgrant, + spec); + if (GNUNET_OK != res) + return res; + /* spec_uint64 also accepts negative JSON integers by unsigned conversion. + PostgreSQL INT8 cannot represent these or other values above INT64_MAX. */ + if ( (grant->tokens_per_period_limit > INT64_MAX) || + (grant->tokens_per_period_stash > INT64_MAX) ) + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "quotas must be between 0 and INT64_MAX")) + ? GNUNET_NO + : GNUNET_SYSERR; + if (grant->tokens_per_period_stash > grant->tokens_per_period_limit) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "tokens_per_period_stash exceeds tokens_per_period_limit")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (grant->key_window_size > TMH_MAX_FOUNTAIN_KEY_WINDOW) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "key_window_size too large")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +enum GNUNET_GenericReturnValue +TMH_fountain_grants_parse ( + struct MHD_Connection *connection, + const json_t *jgrants, + struct TALER_MERCHANTDB_FountainGrant *grants, + unsigned int *grants_len) +{ + size_t len = json_array_size (jgrants); + size_t idx; + json_t *jgrant; + + *grants_len = 0; + GNUNET_assert (json_is_array (jgrants)); + if (len > TMH_MAX_FOUNTAIN_GRANTS) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "too many grants")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + json_array_foreach ((json_t *) jgrants, idx, jgrant) + { + enum GNUNET_GenericReturnValue res; + + res = parse_fountain_grant (connection, + jgrant, + &grants[idx]); + if (GNUNET_OK != res) + return res; + for (size_t j = 0; j < idx; j++) + { + if (0 == strcmp (grants[j].token_family_slug, + grants[idx].token_family_slug)) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "duplicate token_family_slug in grants")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + } + } + *grants_len = (unsigned int) len; + return GNUNET_OK; +} diff --git a/src/backend/taler-merchant-httpd_fountains.h b/src/backend/taler-merchant-httpd_fountains.h @@ -0,0 +1,57 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_fountains.h + * @brief shared validation of private fountain grants + */ +#ifndef TALER_MERCHANT_HTTPD_FOUNTAINS_H +#define TALER_MERCHANT_HTTPD_FOUNTAINS_H + +#include "taler-merchant-httpd.h" +#include "merchant-database/do_insert_fountain.h" + + +/** + * Maximum number of grants in a private fountain request. + */ +#define TMH_MAX_FOUNTAIN_GRANTS 192 + + +/** + * Parse and validate the grants for POST or PATCH of a private fountain. + * Checks the grant count, quota bounds, key windows and duplicate slugs. + * An empty array is valid. Handling an omitted PATCH field is the caller's job. + * + * @param connection connection to report errors on + * @param jgrants JSON array, already checked by the request body parser + * @param[out] grants caller-provided storage for #TMH_MAX_FOUNTAIN_GRANTS entries; + * slug strings borrow their storage from @a jgrants + * @param[out] grants_len number of grants on success, zero on failure + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was queued, + * #GNUNET_SYSERR if queueing the error failed + */ +enum GNUNET_GenericReturnValue +TMH_fountain_grants_parse ( + struct MHD_Connection *connection, + const json_t *jgrants, + struct TALER_MERCHANTDB_FountainGrant *grants, + unsigned int *grants_len); + +#endif diff --git a/src/backend/taler-merchant-httpd_get-fountain-info.c b/src/backend/taler-merchant-httpd_get-fountain-info.c @@ -0,0 +1,449 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_get-fountain-info.c + * @brief implementing GET /fountain/info request handling (DD 98) + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_get-fountain-info.h" +#include "taler-merchant-httpd_token-keys.h" +#include <taler/taler_json_lib.h> +#include "merchant-database/get_fountain_by_secret.h" +#include "merchant-database/get_token_family.h" +#include "merchant-database/iterate_fountain_grants.h" + + +/** + * A grant of the fountain the request authenticated as. + */ +struct GrantInfo +{ + /** + * Slug of the granted token family. + */ + char *token_family_slug; + + /** + * Maximum withdrawals per issue-key validity period. + */ + uint64_t tokens_per_period_limit; + + /** + * Suggested number of tokens to hold per period. + */ + uint64_t tokens_per_period_stash; + + /** + * Number of issue-key slots ahead the wallet may withdraw for. + */ + uint32_t key_window_size; +}; + + +/** + * Request-specific context of a GET /fountain/info request. + */ +struct InfoContext +{ + /** + * Grants of the fountain the request authenticated as. + */ + struct GrantInfo *grants; + + /** + * Length of the @e grants array. + */ + unsigned int grants_len; + + /** + * How often the wallet should re-poll. + */ + struct GNUNET_TIME_Relative poll_freq; + + /** + * Serial of the fountain the request authenticated as. + */ + uint64_t fountain_serial; + + /** + * Time this request is processed at. + */ + struct GNUNET_TIME_Timestamp now; +}; + + +/** + * Release the request-specific context. + * + * @param cls a `struct InfoContext *` + */ +static void +info_context_cleanup (void *cls) +{ + struct InfoContext *ic = cls; + + for (unsigned int i = 0; i < ic->grants_len; i++) + GNUNET_free (ic->grants[i].token_family_slug); + GNUNET_array_grow (ic->grants, + ic->grants_len, + 0); + GNUNET_free (ic); +} + + +/** + * Add a grant of the fountain to the context in @a cls. Database + * calls must not be made from within the iteration, so the grants are + * collected first and expanded into the response afterwards. + * + * @param cls a `struct InfoContext *` + * @param token_family_slug slug of the granted token family + * @param token_family_serial serial of the granted token family + * @param tokens_per_period_limit maximum withdrawals per period + * @param tokens_per_period_stash suggested tokens to hold per period + * @param key_window_size number of slots ahead withdrawals are allowed + */ +static void +add_grant (void *cls, + const char *token_family_slug, + uint64_t token_family_serial, + uint64_t tokens_per_period_limit, + uint64_t tokens_per_period_stash, + uint32_t key_window_size) +{ + struct InfoContext *ic = cls; + struct GrantInfo gi = { + .token_family_slug = GNUNET_strdup (token_family_slug), + .tokens_per_period_limit = tokens_per_period_limit, + .tokens_per_period_stash = tokens_per_period_stash, + .key_window_size = key_window_size + }; + + (void) token_family_serial; + GNUNET_array_append (ic->grants, + ic->grants_len, + gi); +} + + +/** + * Parse the fountain secret from the "Authorization: Bearer ..." + * header of @a connection and compute its hash. + * + * @param connection connection to inspect + * @param[out] h_secret set to the hash of the bearer credential + * @return #GNUNET_OK on success, #GNUNET_NO if the header is + * missing or malformed + */ +static enum GNUNET_GenericReturnValue +parse_fountain_secret (struct MHD_Connection *connection, + struct GNUNET_HashCode *h_secret) +{ + static const char bearer[] = "Bearer "; + const char *auth; + char secret[32]; + + auth = MHD_lookup_connection_value (connection, + MHD_HEADER_KIND, + MHD_HTTP_HEADER_AUTHORIZATION); + if ( (NULL == auth) || + (0 != strncmp (auth, + bearer, + strlen (bearer))) ) + return GNUNET_NO; + auth += strlen (bearer); + while (' ' == *auth) + auth++; + if (GNUNET_OK != + GNUNET_STRINGS_string_to_data (auth, + strlen (auth), + secret, + sizeof (secret))) + return GNUNET_NO; + GNUNET_CRYPTO_hash (secret, + sizeof (secret), + h_secret); + memset (secret, + 0, + sizeof (secret)); + return GNUNET_OK; +} + + +/** + * Authenticate the request by the bearer credential in the + * "Authorization" header and load the grants of the fountain. + * + * @param connection connection to report errors on + * @param instance_id instance the fountain belongs to + * @param[in,out] ic context to initialize + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +authenticate (struct MHD_Connection *connection, + const char *instance_id, + struct InfoContext *ic) +{ + struct GNUNET_HashCode h_secret; + enum GNUNET_DB_QueryStatus qs; + + if (GNUNET_OK != + parse_fountain_secret (connection, + &h_secret)) + { + /* Reply as for an unknown credential, so that the endpoint does + not become an oracle for the shape of valid secrets. */ + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_UNAUTHORIZED, + TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED, + "fountain secret")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + qs = TALER_MERCHANTDB_get_fountain_by_secret (TMH_db, + instance_id, + &h_secret, + &ic->fountain_serial, + &ic->poll_freq); + if (0 > qs) + { + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_fountain_by_secret")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs) + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_UNAUTHORIZED, + TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED, + "fountain secret")) + ? GNUNET_NO + : GNUNET_SYSERR; + qs = TALER_MERCHANTDB_iterate_fountain_grants (TMH_db, + instance_id, + ic->fountain_serial, + &add_grant, + ic); + if (0 > qs) + { + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "iterate_fountain_grants")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Collect the issue keys of the current and the next + * @a key_window_size validity periods into @a family, minting them if + * they do not exist yet. This is the same lazy minting that order + * creation performs, so this GET deliberately writes to the database. + * + * @param connection connection to report errors on + * @param instance_id instance the token family belongs to + * @param ic context of the request + * @param gi grant to expand + * @param tf details of the token family + * @param[in,out] family contract token family to add the keys to + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +collect_keys (struct MHD_Connection *connection, + const char *instance_id, + const struct InfoContext *ic, + const struct GrantInfo *gi, + const struct TALER_MERCHANTDB_TokenFamilyDetails *tf, + struct TALER_MERCHANT_ContractTokenFamily *family) +{ + struct TMH_TokenKeyWindow window; + enum GNUNET_GenericReturnValue res; + + res = TMH_token_key_window_get (connection, + instance_id, + tf, + ic->now, + gi->key_window_size, + &window); + if (GNUNET_OK != res) + return res; + for (unsigned int i = 0; i < window.keys_len; i++) + { + const struct TALER_MERCHANTDB_TokenFamilyKeyDetails *kd = &window.keys[i]; + struct TALER_MERCHANT_ContractTokenFamilyKey key; + + TALER_token_issue_pub_copy (&key.pub, + &kd->pub); + key.valid_after = kd->signature_validity_start; + key.valid_before = kd->signature_validity_end; + GNUNET_array_append (family->keys, + family->keys_len, + key); + } + TMH_token_key_window_free (&window); + return GNUNET_OK; +} + + +/** + * Expand one grant of the fountain into its JSON representation, + * including the token family metadata and its issue keys. + * + * @param connection connection to report errors on + * @param instance_id instance the fountain belongs to + * @param ic context of the request + * @param gi grant to expand + * @param[in,out] jgrants JSON array to append the grant to + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +expand_grant (struct MHD_Connection *connection, + const char *instance_id, + const struct InfoContext *ic, + const struct GrantInfo *gi, + json_t *jgrants) +{ + struct TALER_MERCHANTDB_TokenFamilyDetails tf; + struct TALER_MERCHANT_ContractTokenFamily family; + json_t *jfamily; + enum GNUNET_GenericReturnValue res; + enum GNUNET_DB_QueryStatus qs; + + qs = TALER_MERCHANTDB_get_token_family (TMH_db, + instance_id, + gi->token_family_slug, + &tf); + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) + { + /* Grants cascade-delete with their token family, so the family + must exist; anything else is an internal failure. */ + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_token_family")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + TMH_token_family_to_contract (&tf, + &family); + res = collect_keys (connection, + instance_id, + ic, + gi, + &tf, + &family); + TALER_MERCHANTDB_token_family_details_free (&tf); + if (GNUNET_OK != res) + { + TALER_MERCHANT_contract_token_family_free (&family); + return res; + } + jfamily = TALER_MERCHANT_json_from_token_family (&family); + GNUNET_assert (NULL != jfamily); + TALER_MERCHANT_contract_token_family_free (&family); + GNUNET_assert (0 == + json_array_append_new ( + jgrants, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("token_family_slug", + gi->token_family_slug), + GNUNET_JSON_pack_uint64 ("tokens_per_period_limit", + gi->tokens_per_period_limit), + GNUNET_JSON_pack_uint64 ("tokens_per_period_stash", + gi->tokens_per_period_stash), + GNUNET_JSON_pack_uint64 ("key_window_size", + gi->key_window_size), + GNUNET_JSON_pack_object_steal ("token_family", + jfamily)))); + return GNUNET_OK; +} + + +enum MHD_Result +TMH_get_fountain_info (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + struct InfoContext *ic = hc->ctx; + json_t *jgrants; + enum GNUNET_GenericReturnValue res; + + GNUNET_assert (NULL != mi); + if (NULL == ic) + { + ic = GNUNET_new (struct InfoContext); + ic->now = GNUNET_TIME_timestamp_get (); + hc->ctx = ic; + hc->cc = &info_context_cleanup; + } + res = authenticate (connection, + mi->settings.id, + ic); + if (GNUNET_OK != res) + return (GNUNET_NO == res) + ? MHD_YES + : MHD_NO; + jgrants = json_array (); + GNUNET_assert (NULL != jgrants); + for (unsigned int i = 0; i < ic->grants_len; i++) + { + res = expand_grant (connection, + mi->settings.id, + ic, + &ic->grants[i], + jgrants); + if (GNUNET_OK != res) + { + json_decref (jgrants); + return (GNUNET_NO == res) + ? MHD_YES + : MHD_NO; + } + } + return TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_time_rel ("poll_freq", + ic->poll_freq), + GNUNET_JSON_pack_array_steal ("grants", + jgrants)); +} + + +/* end of taler-merchant-httpd_get-fountain-info.c */ diff --git a/src/backend/taler-merchant-httpd_get-fountain-info.h b/src/backend/taler-merchant-httpd_get-fountain-info.h @@ -0,0 +1,46 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_get-fountain-info.h + * @brief implementing GET /fountain/info request handling (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_GET_FOUNTAIN_INFO_H +#define TALER_MERCHANT_HTTPD_GET_FOUNTAIN_INFO_H + +#include "taler-merchant-httpd.h" + + +/** + * Handle a GET "/fountain/info" request. Public endpoint, + * authenticated by the fountain's bearer credential in the + * "Authorization: Bearer $FOUNTAIN_SECRET" header. + * + * @param rh context of the handler + * @param connection the MHD connection to handle + * @param[in,out] hc context with further information about the request + * @return MHD result code + */ +enum MHD_Result +TMH_get_fountain_info (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +#endif diff --git a/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c b/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c @@ -0,0 +1,132 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c + * @brief implementing GET /private/fountains/$FOUNTAIN_ID request handling + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h" +#include <taler/taler_json_lib.h> +#include "merchant-database/get_fountain.h" +#include "merchant-database/iterate_fountain_grants.h" + + +/** + * Add a fountain grant to the JSON array in @a cls. + * + * @param cls a `json_t *` JSON array to build + * @param token_family_slug slug of the granted token family + * @param token_family_serial serial of the granted token family + * @param tokens_per_period_limit maximum withdrawals per period + * @param tokens_per_period_stash suggested tokens to hold per period + * @param key_window_size number of slots ahead withdrawals are allowed + */ +static void +add_grant (void *cls, + const char *token_family_slug, + uint64_t token_family_serial, + uint64_t tokens_per_period_limit, + uint64_t tokens_per_period_stash, + uint32_t key_window_size) +{ + json_t *ga = cls; + + (void) token_family_serial; + GNUNET_assert (0 == + json_array_append_new ( + ga, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("token_family_slug", + token_family_slug), + GNUNET_JSON_pack_uint64 ("tokens_per_period_limit", + tokens_per_period_limit), + GNUNET_JSON_pack_uint64 ("tokens_per_period_stash", + tokens_per_period_stash), + GNUNET_JSON_pack_uint64 ("key_window_size", + key_window_size)))); +} + + +enum MHD_Result +TMH_private_get_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + struct TALER_MERCHANTDB_FountainDetails fd; + json_t *ga; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_assert (NULL != mi); + GNUNET_assert (NULL != hc->infix); + qs = TALER_MERCHANTDB_get_fountain (TMH_db, + mi->settings.id, + hc->infix, + &fd); + if (0 > qs) + { + GNUNET_break (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_fountain"); + } + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs) + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_FOUNTAIN_UNKNOWN, + hc->infix); + ga = json_array (); + GNUNET_assert (NULL != ga); + qs = TALER_MERCHANTDB_iterate_fountain_grants (TMH_db, + mi->settings.id, + fd.fountain_serial, + &add_grant, + ga); + if (0 > qs) + { + GNUNET_break (0); + json_decref (ga); + GNUNET_free (fd.description); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "iterate_fountain_grants"); + } + { + enum MHD_Result mret; + + mret = TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_string ("description", + fd.description), + GNUNET_JSON_pack_time_rel ("poll_freq", + fd.poll_freq), + GNUNET_JSON_pack_array_steal ("grants", + ga)); + GNUNET_free (fd.description); + return mret; + } +} + + +/* end of taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c */ diff --git a/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h b/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h @@ -0,0 +1,44 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h + * @brief implementing GET /private/fountains/$FOUNTAIN_ID request handling + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H +#define TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H + +#include "taler-merchant-httpd.h" + + +/** + * Handle a GET "/private/fountains/$FOUNTAIN_ID" request. + * + * @param rh context of the handler + * @param connection the MHD connection to handle + * @param[in,out] hc context with further information about the request + * @return MHD result code + */ +enum MHD_Result +TMH_private_get_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +#endif diff --git a/src/backend/taler-merchant-httpd_get-private-fountains.c b/src/backend/taler-merchant-httpd_get-private-fountains.c @@ -0,0 +1,89 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_get-private-fountains.c + * @brief implementing GET /private/fountains request handling + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_get-private-fountains.h" +#include <taler/taler_json_lib.h> +#include "merchant-database/iterate_fountains.h" + + +/** + * Add fountain details to the JSON array in @a cls. + * + * @param cls a `json_t *` JSON array to build + * @param fountain_id public identifier of the fountain + * @param description description of the fountain + */ +static void +add_fountain (void *cls, + const char *fountain_id, + const char *description) +{ + json_t *pa = cls; + + GNUNET_assert (0 == + json_array_append_new ( + pa, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("fountain_id", + fountain_id), + GNUNET_JSON_pack_string ("description", + description)))); +} + + +enum MHD_Result +TMH_private_get_fountains (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + json_t *pa; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_assert (NULL != mi); + pa = json_array (); + GNUNET_assert (NULL != pa); + qs = TALER_MERCHANTDB_iterate_fountains (TMH_db, + mi->settings.id, + &add_fountain, + pa); + if (0 > qs) + { + GNUNET_break (0); + json_decref (pa); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "iterate_fountains"); + } + return TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_array_steal ("fountains", + pa)); +} + + +/* end of taler-merchant-httpd_get-private-fountains.c */ diff --git a/src/backend/taler-merchant-httpd_get-private-fountains.h b/src/backend/taler-merchant-httpd_get-private-fountains.h @@ -0,0 +1,44 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_get-private-fountains.h + * @brief implementing GET /private/fountains request handling + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_H +#define TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_H + +#include "taler-merchant-httpd.h" + + +/** + * Handle a GET "/private/fountains" request. + * + * @param rh context of the handler + * @param connection the MHD connection to handle + * @param[in,out] hc context with further information about the request + * @return MHD result code + */ +enum MHD_Result +TMH_private_get_fountains (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +#endif diff --git a/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c b/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c @@ -0,0 +1,146 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c + * @brief implementing PATCH /private/fountains/$FOUNTAIN_ID request handling + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h" +#include "taler-merchant-httpd_fountains.h" +#include <taler/taler_json_lib.h> +#include "merchant-database/do_update_fountain.h" + + +enum MHD_Result +TMH_private_patch_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + const char *description = NULL; + struct GNUNET_TIME_Relative poll_freq; + bool no_poll_freq; + const json_t *jgrants = NULL; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("description", + &description), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_relative_time ("poll_freq", + &poll_freq), + &no_poll_freq), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_array_const ("grants", + &jgrants), + NULL), + GNUNET_JSON_spec_end () + }; + unsigned int grants_len = 0; + bool replace_grants; + + GNUNET_assert (NULL != mi); + GNUNET_assert (NULL != hc->infix); + { + enum GNUNET_GenericReturnValue res; + + res = TALER_MHD_parse_json_data (connection, + hc->request_body, + spec); + if (GNUNET_OK != res) + { + GNUNET_break_op (0); + return (GNUNET_NO == res) + ? MHD_YES + : MHD_NO; + } + } + replace_grants = (NULL != jgrants); + { + struct TALER_MERCHANTDB_FountainGrant grants[TMH_MAX_FOUNTAIN_GRANTS]; + char *unknown_slug; + bool not_found; + enum GNUNET_DB_QueryStatus qs; + + if (replace_grants) + { + enum GNUNET_GenericReturnValue res; + + res = TMH_fountain_grants_parse (connection, + jgrants, + grants, + &grants_len); + if (GNUNET_OK != res) + { + GNUNET_JSON_parse_free (spec); + return (GNUNET_NO == res) + ? MHD_YES + : MHD_NO; + } + } + qs = TALER_MERCHANTDB_do_update_fountain (TMH_db, + mi->settings.id, + hc->infix, + description, + no_poll_freq + ? NULL + : &poll_freq, + replace_grants, + grants_len, + grants, + &not_found, + &unknown_slug); + GNUNET_JSON_parse_free (spec); + if (qs < 0) + { + GNUNET_break (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "do_update_fountain"); + } + if (not_found) + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_FOUNTAIN_UNKNOWN, + hc->infix); + if (NULL != unknown_slug) + { + enum MHD_Result mret; + + mret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN, + unknown_slug); + GNUNET_free (unknown_slug); + return mret; + } + } + return TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); +} + + +/* end of taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c */ diff --git a/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h b/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h @@ -0,0 +1,44 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h + * @brief implementing PATCH /private/fountains/$FOUNTAIN_ID request handling + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H +#define TALER_MERCHANT_HTTPD_PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H + +#include "taler-merchant-httpd.h" + + +/** + * Handle a PATCH "/private/fountains/$FOUNTAIN_ID" request. + * + * @param rh context of the handler + * @param connection the MHD connection to handle + * @param[in,out] hc context with further information about the request + * @return MHD result code + */ +enum MHD_Result +TMH_private_patch_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +#endif diff --git a/src/backend/taler-merchant-httpd_post-fountain-withdraw.c b/src/backend/taler-merchant-httpd_post-fountain-withdraw.c @@ -0,0 +1,1284 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_post-fountain-withdraw.c + * @brief implementing POST /fountain/withdraw request handling (DD 98) + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_post-fountain-withdraw.h" +#include "taler-merchant-httpd_token-keys.h" +#include <taler/taler_json_lib.h> +#include "merchant-database/do_fountain_withdraw.h" +#include "merchant-database/get_fountain_by_secret.h" +#include "merchant-database/get_fountain_withdraw.h" +#include "merchant-database/get_token_family.h" +#include "merchant-database/insert_fountain_withdraw_sig.h" +#include "merchant-database/insert_issued_token.h" +#include "merchant-database/iterate_fountain_grants.h" +#include "merchant-database/start.h" + + +/** + * Phases of processing a fountain withdrawal. + */ +enum WithdrawPhase +{ + /** + * Parse the request envelope. + */ + WP_PARSE_REQUEST = 0, + + /** + * Authenticate the fountain credential. + */ + WP_AUTHENTICATE, + + /** + * Start the withdrawal transaction. + */ + WP_START_TRANSACTION, + + /** + * Lock the fountain and restore any completed withdrawal. + */ + WP_CHECK_REPLAY, + + /** + * Load the current grants for a new withdrawal. + */ + WP_LOAD_GRANTS, + + /** + * Parse the requested tokens and check their grants. + */ + WP_PARSE_ENTRIES, + + /** + * Resolve issue keys and reject duplicate slots. + */ + WP_RESOLVE_KEYS, + + /** + * Check and consume the quota for all entries. + */ + WP_CONSUME_QUOTA, + + /** + * Sign tokens and store issued-token and replay records. + */ + WP_SIGN_TOKENS, + + /** + * Commit the withdrawal before sending its result. + */ + WP_COMMIT_TRANSACTION, + + /** + * Return the newly issued or restored signatures. + */ + WP_SUCCESS_RESPONSE, + + /** + * Return #MHD_YES to end processing. + */ + WP_END_YES, + + /** + * Return #MHD_NO to end processing. + */ + WP_END_NO +}; + + +/** + * A grant of the fountain the request authenticated as. + */ +struct GrantInfo +{ + /** + * Slug of the granted token family. + */ + char *token_family_slug; + + /** + * Serial of the granted token family. + */ + uint64_t token_family_serial; + + /** + * Number of issue-key slots ahead the wallet may withdraw for. + */ + uint32_t key_window_size; +}; + + +/** + * One entry of the withdraw request being processed. + */ +struct WithdrawEntry +{ + /** + * Slug of the token family withdrawn from; aliases into the + * request body. + */ + const char *token_family_slug; + + /** + * Desired token validity time; selects a key from the grant's window. + */ + struct GNUNET_TIME_Timestamp valid_at; + + /** + * Blinded envelopes to sign. + */ + struct TALER_TokenEnvelope *envelopes; + + /** + * Length of the @e envelopes array. + */ + unsigned int envelopes_len; + + /** + * Serial of the token family. + */ + uint64_t token_family_serial; + + /** + * Issue key (and token family details) of the selected slot. + */ + struct TALER_MERCHANTDB_TokenFamilyKeyDetails kd; + + /** + * True if @e kd was initialized and must be released. + */ + bool have_kd; +}; + + +/** + * Request-specific context of a POST /fountain/withdraw request. + */ +struct WithdrawContext +{ + /** + * Connection to respond on. + */ + struct MHD_Connection *connection; + + /** + * Handler context, including the instance and request body. + */ + struct TMH_HandlerContext *hc; + + /** + * Current processing phase. + */ + enum WithdrawPhase phase; + + /** + * Fountain credential borrowed from the request body. + */ + const char *fountain_secret; + + /** + * JSON array of withdrawal entries borrowed from the request body. + */ + const json_t *jentries; + + /** + * Canonical hash identifying this withdrawal within the fountain. + */ + struct GNUNET_HashCode h_request; + + /** + * Grant results to return after replay or successful commit. + */ + json_t *results; + + /** + * True while this synchronous handler owns an open transaction. + */ + bool transaction_open; + + /** + * Grants of the fountain the request authenticated as. + */ + struct GrantInfo *grants; + + /** + * Length of the @e grants array. + */ + unsigned int grants_len; + + /** + * Entries of the request. + */ + struct WithdrawEntry *entries; + + /** + * Length of the @e entries array. + */ + unsigned int entries_len; + + /** + * Serial of the fountain the request authenticated as. + */ + uint64_t fountain_serial; + + /** + * Time this request is processed at. + */ + struct GNUNET_TIME_Timestamp now; +}; + + +/** + * Release the request-specific context. + * + * @param cls a `struct WithdrawContext *` + */ +static void +withdraw_context_cleanup (void *cls) +{ + struct WithdrawContext *wc = cls; + + for (unsigned int i = 0; i < wc->grants_len; i++) + GNUNET_free (wc->grants[i].token_family_slug); + GNUNET_array_grow (wc->grants, + wc->grants_len, + 0); + /* @e entries is only allocated once the request body was parsed; + @e entries_len is known before that. */ + for (unsigned int i = 0; (NULL != wc->entries) && (i < wc->entries_len); i++) + { + struct WithdrawEntry *we = &wc->entries[i]; + + for (unsigned int j = 0; j < we->envelopes_len; j++) + if (NULL != we->envelopes[j].blinded_pub) + GNUNET_CRYPTO_blinded_message_decref (we->envelopes[j].blinded_pub); + GNUNET_free (we->envelopes); + if (we->have_kd) + TMH_token_key_details_free (&we->kd); + } + GNUNET_free (wc->entries); + json_decref (wc->results); + GNUNET_free (wc); +} + + +/** + * Add a grant of the fountain to the context in @a cls. + * + * @param cls a `struct WithdrawContext *` + * @param token_family_slug slug of the granted token family + * @param token_family_serial serial of the granted token family + * @param tokens_per_period_limit maximum withdrawals per period + * @param tokens_per_period_stash suggested tokens to hold per period + * @param key_window_size number of slots ahead withdrawals are allowed + */ +static void +add_grant (void *cls, + const char *token_family_slug, + uint64_t token_family_serial, + uint64_t tokens_per_period_limit, + uint64_t tokens_per_period_stash, + uint32_t key_window_size) +{ + struct WithdrawContext *wc = cls; + struct GrantInfo gi = { + .token_family_slug = GNUNET_strdup (token_family_slug), + .token_family_serial = token_family_serial, + .key_window_size = key_window_size + }; + + (void) tokens_per_period_limit; + (void) tokens_per_period_stash; + GNUNET_array_append (wc->grants, + wc->grants_len, + gi); +} + + +/** + * Find the grant for @a slug among the grants of the fountain. + * + * @param wc context to search + * @param slug token family slug to look for + * @return NULL if the fountain does not grant @a slug + */ +static const struct GrantInfo * +find_grant (const struct WithdrawContext *wc, + const char *slug) +{ + for (unsigned int i = 0; i < wc->grants_len; i++) + if (0 == strcmp (wc->grants[i].token_family_slug, + slug)) + return &wc->grants[i]; + return NULL; +} + + +/** + * Authenticate the request using the supplied fountain secret. + * + * @param[in,out] wc context to initialize + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +phase_authenticate (struct WithdrawContext *wc) +{ + struct MHD_Connection *connection = wc->connection; + const char *instance_id = wc->hc->instance->settings.id; + const char *fountain_secret = wc->fountain_secret; + struct GNUNET_HashCode h_secret; + struct GNUNET_TIME_Relative poll_freq; + enum GNUNET_DB_QueryStatus qs; + + { + char secret[32]; + + if (GNUNET_OK != + GNUNET_STRINGS_string_to_data (fountain_secret, + strlen (fountain_secret), + secret, + sizeof (secret))) + { + /* Reply as for an unknown credential, so that the endpoint does + not become an oracle for the shape of valid secrets. */ + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_UNAUTHORIZED, + TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED, + "fountain secret")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + GNUNET_CRYPTO_hash (secret, + sizeof (secret), + &h_secret); + memset (secret, + 0, + sizeof (secret)); + } + qs = TALER_MERCHANTDB_get_fountain_by_secret (TMH_db, + instance_id, + &h_secret, + &wc->fountain_serial, + &poll_freq); + if (0 > qs) + { + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_fountain_by_secret")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs) + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_UNAUTHORIZED, + TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED, + "fountain secret")) + ? GNUNET_NO + : GNUNET_SYSERR; + wc->phase = WP_START_TRANSACTION; + return GNUNET_OK; +} + + +/** + * Load current grants after locking the fountain and checking for a replay. + * + * @param[in,out] wc context to populate + * @return #GNUNET_OK on success, #GNUNET_NO if an error was queued, + * #GNUNET_SYSERR if queueing failed + */ +static enum GNUNET_GenericReturnValue +phase_load_grants (struct WithdrawContext *wc) +{ + struct MHD_Connection *connection = wc->connection; + const char *instance_id = wc->hc->instance->settings.id; + enum GNUNET_DB_QueryStatus qs; + + qs = TALER_MERCHANTDB_iterate_fountain_grants (TMH_db, + instance_id, + wc->fountain_serial, + &add_grant, + wc); + if (0 > qs) + { + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "iterate_fountain_grants")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + wc->phase = WP_PARSE_ENTRIES; + return GNUNET_OK; +} + + +/** + * Parse the envelopes of one request entry. + * + * @param connection connection to report errors on + * @param jenvelopes JSON array of token envelopes + * @param total_envelopes total number of envelopes parsed so far + * @param[in,out] we entry to complete + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +parse_envelopes (struct MHD_Connection *connection, + const json_t *jenvelopes, + unsigned int total_envelopes, + struct WithdrawEntry *we) +{ + unsigned int len = (unsigned int) json_array_size (jenvelopes); + size_t idx; + json_t *jev; + + if ( (0 == len) || + (total_envelopes + len > TMH_MAX_FOUNTAIN_ENVELOPES) ) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "'envelopes' empty or too many envelopes")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + we->envelopes = GNUNET_new_array (len, + struct TALER_TokenEnvelope); + we->envelopes_len = len; + json_array_foreach ((json_t *) jenvelopes, idx, jev) + { + struct GNUNET_JSON_Specification ispec[] = { + TALER_JSON_spec_token_envelope (NULL, + &we->envelopes[idx]), + GNUNET_JSON_spec_end () + }; + enum GNUNET_GenericReturnValue res; + + res = TALER_MHD_parse_json_data (connection, + jev, + ispec); + if (GNUNET_OK != res) + { + GNUNET_break_op (0); + return res; + } + } + return GNUNET_OK; +} + + +/** + * Parse the entries of the request into @a wc, checking each against + * the grants of the fountain. Resolve keys after all envelopes are parsed. + * + * @param[in,out] wc context to complete + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +phase_parse_entries (struct WithdrawContext *wc) +{ + struct MHD_Connection *connection = wc->connection; + const json_t *jentries = wc->jentries; + unsigned int total_envelopes = 0; + size_t idx; + json_t *jentry; + + wc->entries = GNUNET_new_array (wc->entries_len, + struct WithdrawEntry); + json_array_foreach ((json_t *) jentries, idx, jentry) + { + struct WithdrawEntry *we = &wc->entries[idx]; + const json_t *jenvelopes; + const struct GrantInfo *gi; + enum GNUNET_GenericReturnValue res; + struct GNUNET_JSON_Specification espec[] = { + GNUNET_JSON_spec_string ("token_family_slug", + &we->token_family_slug), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_timestamp ("valid_at", + &we->valid_at), + NULL), + GNUNET_JSON_spec_array_const ("envelopes", + &jenvelopes), + GNUNET_JSON_spec_end () + }; + + we->valid_at = wc->now; + res = TALER_MHD_parse_json_data (connection, + jentry, + espec); + if (GNUNET_OK != res) + { + GNUNET_break_op (0); + return res; + } + gi = find_grant (wc, + we->token_family_slug); + if (NULL == gi) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_GRANT_UNKNOWN, + we->token_family_slug)) + ? GNUNET_NO + : GNUNET_SYSERR; + } + we->token_family_serial = gi->token_family_serial; + res = parse_envelopes (connection, + jenvelopes, + total_envelopes, + we); + if (GNUNET_OK != res) + return res; + total_envelopes += we->envelopes_len; + } + wc->phase = WP_RESOLVE_KEYS; + return GNUNET_OK; +} + + +/** + * Select each entry's key from the same expiry-based window advertised + * by GET /fountain/info, and reject duplicate (token family, key) pairs. + * + * @param[in,out] wc context to complete + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +phase_resolve_keys (struct WithdrawContext *wc) +{ + struct MHD_Connection *connection = wc->connection; + const char *instance_id = wc->hc->instance->settings.id; + + for (unsigned int i = 0; i < wc->entries_len; i++) + { + struct WithdrawEntry *we = &wc->entries[i]; + const struct GrantInfo *gi; + struct TALER_MERCHANTDB_TokenFamilyDetails tf; + struct TMH_TokenKeyWindow window; + unsigned int key_index = 0; + enum GNUNET_GenericReturnValue res; + enum GNUNET_DB_QueryStatus qs; + + gi = find_grant (wc, + we->token_family_slug); + GNUNET_assert (NULL != gi); + qs = TALER_MERCHANTDB_get_token_family (TMH_db, + instance_id, + we->token_family_slug, + &tf); + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) + { + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_token_family")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + res = TMH_token_key_window_get (connection, + instance_id, + &tf, + wc->now, + gi->key_window_size, + &window); + TALER_MERCHANTDB_token_family_details_free (&tf); + if (GNUNET_OK != res) + return res; + if ( (0 == window.keys_len) || + (GNUNET_OK != + TMH_token_key_window_find (&window, + we->valid_at, + &key_index)) ) + { + TMH_token_key_window_free (&window); + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_SLOT_OUTSIDE_WINDOW, + we->token_family_slug)) + ? GNUNET_NO + : GNUNET_SYSERR; + } + /* Transfer the selected key, rather than looking it up again by a + timestamp that an overlapping earlier key could also cover. */ + we->kd = window.keys[key_index]; + memset (&window.keys[key_index], + 0, + sizeof (window.keys[key_index])); + we->have_kd = true; + TMH_token_key_window_free (&window); + if (NULL == we->kd.priv.private_key) + { + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "issue private key unavailable")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + for (unsigned int j = 0; j < we->envelopes_len; j++) + { + if (we->envelopes[j].blinded_pub->cipher != + we->kd.priv.private_key->cipher) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "envelope cipher does not match issue key")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + } + for (unsigned int j = 0; j < i; j++) + { + if ( (wc->entries[j].token_family_serial == + we->token_family_serial) && + (GNUNET_TIME_timestamp_cmp ( + wc->entries[j].kd.signature_validity_start, + ==, + we->kd.signature_validity_start)) ) + { + GNUNET_break_op (0); + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "multiple entries for one token family and key slot")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + } + } + wc->phase = WP_CONSUME_QUOTA; + return GNUNET_OK; +} + + +/** + * Atomically check and consume the per-period withdrawal quota of + * all entries. Either the whole request fits, or nothing is + * consumed. + * + * @param[in,out] wc context of the request + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +phase_consume_quota (struct WithdrawContext *wc) +{ + struct MHD_Connection *connection = wc->connection; + const char *instance_id = wc->hc->instance->settings.id; + uint64_t family_serials[GNUNET_NZL (wc->entries_len)]; + struct GNUNET_TIME_Timestamp slot_starts[GNUNET_NZL (wc->entries_len)]; + uint64_t counts[GNUNET_NZL (wc->entries_len)]; + unsigned int failed_index; + bool no_grant; + bool exceeded; + enum GNUNET_DB_QueryStatus qs; + + for (unsigned int i = 0; i < wc->entries_len; i++) + { + family_serials[i] = wc->entries[i].token_family_serial; + slot_starts[i] = wc->entries[i].kd.signature_validity_start; + counts[i] = wc->entries[i].envelopes_len; + } + qs = TALER_MERCHANTDB_do_fountain_withdraw (TMH_db, + instance_id, + wc->fountain_serial, + wc->entries_len, + family_serials, + slot_starts, + counts, + &failed_index, + &no_grant, + &exceeded); + if (0 > qs) + { + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "do_fountain_withdraw")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (exceeded) + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_TOO_MANY_REQUESTS, + TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_LIMIT_EXCEEDED, + wc->entries[failed_index].token_family_slug)) + ? GNUNET_NO + : GNUNET_SYSERR; + if (no_grant) + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_GRANT_UNKNOWN, + wc->entries[failed_index].token_family_slug)) + ? GNUNET_NO + : GNUNET_SYSERR; + wc->phase = WP_SIGN_TOKENS; + return GNUNET_OK; +} + + +/** + * Blind-sign the envelopes of all entries, record the issued tokens + * and construct the grant results. The caller must commit the quota, + * issued-token records and replay results together before responding. + * + * @param[in,out] wc context of the request + * @return #GNUNET_OK on success, #GNUNET_NO if an error was queued, + * #GNUNET_SYSERR if queueing failed + */ +static enum GNUNET_GenericReturnValue +phase_sign_tokens (struct WithdrawContext *wc) +{ + struct MHD_Connection *connection = wc->connection; + const struct GNUNET_HashCode *h_request = &wc->h_request; + json_t *jresults; + + jresults = json_array (); + GNUNET_assert (NULL != jresults); + for (unsigned int i = 0; i < wc->entries_len; i++) + { + const struct WithdrawEntry *we = &wc->entries[i]; + struct TALER_TokenIssuePublicKeyHashP h_issue = { + .hash = we->kd.pub.public_key->pub_key_hash + }; + json_t *jsigs; + + jsigs = json_array (); + GNUNET_assert (NULL != jsigs); + for (unsigned int j = 0; j < we->envelopes_len; j++) + { + struct TALER_BlindedTokenIssueSignature sig; + enum GNUNET_DB_QueryStatus qs; + bool no_family; + + TALER_token_issue_sign (&we->kd.priv, + &we->envelopes[j], + &sig); + if (NULL == sig.signature) + { + GNUNET_break (0); + json_decref (jsigs); + json_decref (jresults); + return (MHD_YES == TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "token_issue_sign")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + qs = TALER_MERCHANTDB_insert_issued_token (TMH_db, + NULL, + &h_issue, + &sig, + &no_family); + if (0 > qs) + { + GNUNET_break (0); + GNUNET_CRYPTO_blinded_sig_decref (sig.signature); + json_decref (jsigs); + json_decref (jresults); + return (MHD_YES == TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "insert_issued_token")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (no_family) + { + /* The token family key was deleted after resolving the grant, + so we cannot issue this token anymore. */ + GNUNET_break_op (0); + GNUNET_CRYPTO_blinded_sig_decref (sig.signature); + json_decref (jsigs); + json_decref (jresults); + return (MHD_YES == TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_TOKEN_KEY_UNKNOWN, + NULL)) + ? GNUNET_NO + : GNUNET_SYSERR; + } + qs = TALER_MERCHANTDB_insert_fountain_withdraw_sig (TMH_db, + wc->fountain_serial, + h_request, + i, + j, + &h_issue, + &sig); + if (0 >= qs) + { + GNUNET_break (0); + GNUNET_CRYPTO_blinded_sig_decref (sig.signature); + json_decref (jsigs); + json_decref (jresults); + return (MHD_YES == TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "insert_fountain_withdraw_sig")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + GNUNET_assert (0 == + json_array_append_new ( + jsigs, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_blinded_sig ("blind_sig", + sig.signature)))); + GNUNET_CRYPTO_blinded_sig_decref (sig.signature); + } + GNUNET_assert (0 == + json_array_append_new ( + jresults, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("token_family_slug", + we->token_family_slug), + GNUNET_JSON_pack_data_auto ("h_issue", + &h_issue), + GNUNET_JSON_pack_array_steal ("token_sigs", + jsigs)))); + } + wc->results = jresults; + wc->phase = WP_COMMIT_TRANSACTION; + return GNUNET_OK; +} + + +/** + * Closure for #restore_grant_cb(). + */ +struct RestoreState +{ + /** + * Grant results rebuilt so far. + */ + json_t *grants; + + /** + * Signatures of the grant currently being rebuilt. + */ + json_t *sigs; + + /** + * Slug of that grant. + */ + char *slug; + + /** + * Issue key hash of that grant. + */ + struct TALER_TokenIssuePublicKeyHashP h_issue; + + /** + * Index of that grant in the original request. + */ + uint32_t grant_index; + + /** + * True once a grant has been started, so @e slug and @e h_issue + * are meaningful. + */ + bool started; +}; + + +/** + * Append the grant currently being rebuilt to the results. + * + * @param[in,out] rs state to flush + */ +static void +flush_grant (struct RestoreState *rs) +{ + if (! rs->started) + return; + GNUNET_assert (0 == + json_array_append_new ( + rs->grants, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("token_family_slug", + rs->slug), + GNUNET_JSON_pack_data_auto ("h_issue", + &rs->h_issue), + GNUNET_JSON_pack_array_steal ("token_sigs", + rs->sigs)))); + GNUNET_free (rs->slug); + rs->sigs = NULL; + rs->started = false; +} + + +/** + * Rebuild the response of an earlier withdrawal from its stored + * signatures. Rows arrive in response order, so a change of + * @a grant_index closes the grant being assembled. + * + * @param cls a `struct RestoreState *` + * @param grant_index offset of the grant in the original request + * @param token_family_slug token family of that grant + * @param h_issue issue key the signature was made with + * @param blind_sig the blind signature handed out originally + */ +static void +restore_grant_cb (void *cls, + uint32_t grant_index, + const char *token_family_slug, + const struct TALER_TokenIssuePublicKeyHashP *h_issue, + const struct GNUNET_CRYPTO_BlindedSignature *blind_sig) +{ + struct RestoreState *rs = cls; + + if ( (! rs->started) || + (grant_index != rs->grant_index) ) + { + flush_grant (rs); + rs->sigs = json_array (); + GNUNET_assert (NULL != rs->sigs); + rs->slug = GNUNET_strdup (token_family_slug); + rs->h_issue = *h_issue; + rs->grant_index = grant_index; + rs->started = true; + } + GNUNET_assert (0 == + json_array_append_new ( + rs->sigs, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_blinded_sig ( + "blind_sig", + (struct GNUNET_CRYPTO_BlindedSignature *) blind_sig)))); +} + + +/** + * Parse the top-level request without inspecting the current grants. + * + * @param[in,out] wc request context + * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued, + * #GNUNET_SYSERR if queueing failed + */ +static enum GNUNET_GenericReturnValue +phase_parse_request (struct WithdrawContext *wc) +{ + struct MHD_Connection *connection = wc->connection; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("fountain_secret", + &wc->fountain_secret), + GNUNET_JSON_spec_array_const ("grants", + &wc->jentries), + GNUNET_JSON_spec_end () + }; + enum GNUNET_GenericReturnValue res; + + res = TALER_MHD_parse_json_data (connection, + wc->hc->request_body, + spec); + if (GNUNET_OK != res) + { + GNUNET_break_op (0); + return res; + } + { + size_t len = json_array_size (wc->jentries); + + if (len > TMH_MAX_FOUNTAIN_ENVELOPES) + { + GNUNET_break_op (0); + return (MHD_YES == TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "'grants' array too long")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + wc->entries_len = (unsigned int) len; + } + wc->phase = WP_AUTHENTICATE; + return GNUNET_OK; +} + + +/** + * Start the transaction covering replay, quotas and token issuance. + * + * @param[in,out] wc request context + * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued, + * #GNUNET_SYSERR if queueing failed + */ +static enum GNUNET_GenericReturnValue +phase_start_transaction (struct WithdrawContext *wc) +{ + TALER_json_hash (wc->jentries, + &wc->h_request); + if (GNUNET_OK != TALER_MERCHANTDB_start_read_committed ( + TMH_db, + "fountain withdraw")) + return (MHD_YES == TALER_MHD_reply_with_error ( + wc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_START_FAILED, + NULL)) + ? GNUNET_NO + : GNUNET_SYSERR; + wc->transaction_open = true; + wc->phase = WP_CHECK_REPLAY; + return GNUNET_OK; +} + + +/** + * Lock the fountain and restore a completed withdrawal, if any. + * + * @param[in,out] wc request context + * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued, + * #GNUNET_SYSERR if queueing failed + */ +static enum GNUNET_GenericReturnValue +phase_check_replay (struct WithdrawContext *wc) +{ + struct RestoreState rs = { 0 }; + enum GNUNET_DB_QueryStatus qs; + bool not_found; + + /* Serialize withdrawals of this fountain. Replay before consulting + grants or key windows, which may have changed since the first call. */ + rs.grants = json_array (); + GNUNET_assert (NULL != rs.grants); + qs = TALER_MERCHANTDB_get_fountain_withdraw (TMH_db, + wc->fountain_serial, + &wc->h_request, + &not_found, + &restore_grant_cb, + &rs); + flush_grant (&rs); + if (qs < 0) + { + json_decref (rs.grants); + return (MHD_YES == TALER_MHD_reply_with_error ( + wc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_fountain_withdraw")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (not_found) + { + json_decref (rs.grants); + return (MHD_YES == TALER_MHD_reply_with_error ( + wc->connection, + MHD_HTTP_UNAUTHORIZED, + TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED, + "fountain secret")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (0 != json_array_size (rs.grants)) + { + TALER_MERCHANTDB_rollback (TMH_db); + wc->transaction_open = false; + wc->results = rs.grants; + wc->phase = WP_SUCCESS_RESPONSE; + return GNUNET_OK; + } + json_decref (rs.grants); + wc->phase = WP_LOAD_GRANTS; + return GNUNET_OK; +} + + +/** + * Commit quota, issued tokens and replay records together. + * + * @param[in,out] wc request context + * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued, + * #GNUNET_SYSERR if queueing failed + */ +static enum GNUNET_GenericReturnValue +phase_commit_transaction (struct WithdrawContext *wc) +{ + enum GNUNET_DB_QueryStatus qs; + + qs = TALER_MERCHANTDB_commit (TMH_db); + if (qs < 0) + { + return (MHD_YES == TALER_MHD_reply_with_error ( + wc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_COMMIT_FAILED, + NULL)) + ? GNUNET_NO + : GNUNET_SYSERR; + } + wc->transaction_open = false; + wc->phase = WP_SUCCESS_RESPONSE; + return GNUNET_OK; +} + + +/** + * Return the newly issued or restored grant results. + * + * @param[in,out] wc request context + * @return #GNUNET_NO if the response was queued, #GNUNET_SYSERR otherwise + */ +static enum GNUNET_GenericReturnValue +phase_success_response (struct WithdrawContext *wc) +{ + enum MHD_Result ret; + + GNUNET_assert (! wc->transaction_open); + ret = TALER_MHD_REPLY_JSON_PACK ( + wc->connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_array_steal ("grants", + wc->results)); + wc->results = NULL; + return (MHD_YES == ret) ? GNUNET_NO : GNUNET_SYSERR; +} + + +enum MHD_Result +TMH_post_fountain_withdraw (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct WithdrawContext *wc = hc->ctx; + + GNUNET_assert (NULL != hc->instance); + if (NULL == wc) + { + wc = GNUNET_new (struct WithdrawContext); + wc->connection = connection; + wc->hc = hc; + wc->now = GNUNET_TIME_timestamp_get (); + hc->ctx = wc; + hc->cc = &withdraw_context_cleanup; + } + while (1) + { + enum GNUNET_GenericReturnValue res; + + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Processing /fountain/withdraw in phase %d\n", + (int) wc->phase); + switch (wc->phase) + { + case WP_PARSE_REQUEST: + res = phase_parse_request (wc); + break; + case WP_AUTHENTICATE: + res = phase_authenticate (wc); + break; + case WP_START_TRANSACTION: + res = phase_start_transaction (wc); + break; + case WP_CHECK_REPLAY: + res = phase_check_replay (wc); + break; + case WP_LOAD_GRANTS: + res = phase_load_grants (wc); + break; + case WP_PARSE_ENTRIES: + res = phase_parse_entries (wc); + break; + case WP_RESOLVE_KEYS: + res = phase_resolve_keys (wc); + break; + case WP_CONSUME_QUOTA: + res = phase_consume_quota (wc); + break; + case WP_SIGN_TOKENS: + res = phase_sign_tokens (wc); + break; + case WP_COMMIT_TRANSACTION: + res = phase_commit_transaction (wc); + break; + case WP_SUCCESS_RESPONSE: + res = phase_success_response (wc); + break; + case WP_END_YES: + return MHD_YES; + case WP_END_NO: + return MHD_NO; + default: + GNUNET_assert (0); + return MHD_NO; + } + if (GNUNET_OK != res) + { + /* All phases run synchronously: release the transaction before + returning control to the HTTP server, including on queueing errors. */ + if (wc->transaction_open) + { + TALER_MERCHANTDB_rollback (TMH_db); + wc->transaction_open = false; + } + wc->phase = (GNUNET_NO == res) ? WP_END_YES : WP_END_NO; + } + } +} + + +/* end of taler-merchant-httpd_post-fountain-withdraw.c */ diff --git a/src/backend/taler-merchant-httpd_post-fountain-withdraw.h b/src/backend/taler-merchant-httpd_post-fountain-withdraw.h @@ -0,0 +1,47 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_post-fountain-withdraw.h + * @brief implementing POST /fountain/withdraw request handling (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_POST_FOUNTAIN_WITHDRAW_H +#define TALER_MERCHANT_HTTPD_POST_FOUNTAIN_WITHDRAW_H + +#include "taler-merchant-httpd.h" + + +/** + * Handle a POST "/fountain/withdraw" request. Public endpoint, + * authenticated by the fountain's bearer credential in the request + * body. Blind-signs the submitted token envelopes within the + * fountain's per-period withdrawal limits. + * + * @param rh context of the handler + * @param connection the MHD connection to handle + * @param[in,out] hc context with further information about the request + * @return MHD result code + */ +enum MHD_Result +TMH_post_fountain_withdraw (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +#endif diff --git a/src/backend/taler-merchant-httpd_post-private-fountains.c b/src/backend/taler-merchant-httpd_post-private-fountains.c @@ -0,0 +1,186 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_post-private-fountains.c + * @brief implementing POST /private/fountains request handling + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_post-private-fountains.h" +#include "taler-merchant-httpd_helper.h" +#include "taler-merchant-httpd_fountains.h" +#include <taler/taler_json_lib.h> +#include "merchant-database/do_insert_fountain.h" + + +/** + * How often do we retry on a (astronomically unlikely) random + * collision of the fountain identifier or secret? + */ +#define MAX_RETRIES 3 + + +enum MHD_Result +TMH_private_post_fountains (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + const char *description; + struct GNUNET_TIME_Relative poll_freq; + const json_t *jgrants; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("description", + &description), + GNUNET_JSON_spec_relative_time ("poll_freq", + &poll_freq), + GNUNET_JSON_spec_array_const ("grants", + &jgrants), + GNUNET_JSON_spec_end () + }; + unsigned int grants_len; + + GNUNET_assert (NULL != mi); + { + enum GNUNET_GenericReturnValue res; + + res = TALER_MHD_parse_json_data (connection, + hc->request_body, + spec); + if (GNUNET_OK != res) + { + GNUNET_break_op (0); + return (GNUNET_NO == res) + ? MHD_YES + : MHD_NO; + } + } + { + struct TALER_MERCHANTDB_FountainGrant grants[TMH_MAX_FOUNTAIN_GRANTS]; + enum GNUNET_GenericReturnValue res; + + res = TMH_fountain_grants_parse (connection, + jgrants, + grants, + &grants_len); + if (GNUNET_OK != res) + { + GNUNET_JSON_parse_free (spec); + return (GNUNET_NO == res) + ? MHD_YES + : MHD_NO; + } + + for (unsigned int attempt = 0; attempt < MAX_RETRIES; attempt++) + { + char secret[32]; + struct GNUNET_HashCode h_secret; + char *fountain_id; + char *fountain_secret; + char *unknown_slug; + bool conflict; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_CRYPTO_random_block (secret, + sizeof (secret)); + GNUNET_CRYPTO_hash (secret, + sizeof (secret), + &h_secret); + { + char rnd[16]; + + GNUNET_CRYPTO_random_block (rnd, + sizeof (rnd)); + fountain_id = GNUNET_STRINGS_data_to_string_alloc (rnd, + sizeof (rnd)); + } + qs = TALER_MERCHANTDB_do_insert_fountain (TMH_db, + mi->settings.id, + fountain_id, + &h_secret, + description, + poll_freq, + grants_len, + grants, + &unknown_slug, + &conflict); + if (qs < 0) + { + GNUNET_break (0); + GNUNET_free (fountain_id); + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "do_insert_fountain"); + } + if (NULL != unknown_slug) + { + enum MHD_Result mret; + + GNUNET_free (fountain_id); + GNUNET_JSON_parse_free (spec); + mret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN, + unknown_slug); + GNUNET_free (unknown_slug); + return mret; + } + if (conflict) + { + /* Random collision of fountain_id or secret hash; try again + with fresh randomness. */ + GNUNET_free (fountain_id); + continue; + } + fountain_secret = GNUNET_STRINGS_data_to_string_alloc (secret, + sizeof (secret)); + memset (secret, + 0, + sizeof (secret)); + GNUNET_JSON_parse_free (spec); + { + enum MHD_Result mret; + + mret = TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_string ("fountain_id", + fountain_id), + GNUNET_JSON_pack_string ("fountain_secret", + fountain_secret)); + GNUNET_free (fountain_id); + GNUNET_free (fountain_secret); + return mret; + } + } + } + GNUNET_break (0); + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "repeated fountain id collision"); +} + + +/* end of taler-merchant-httpd_post-private-fountains.c */ diff --git a/src/backend/taler-merchant-httpd_post-private-fountains.h b/src/backend/taler-merchant-httpd_post-private-fountains.h @@ -0,0 +1,44 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3, + or (at your option) any later version. + + 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 TALER; see the file COPYING. If not, + see <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/backend/taler-merchant-httpd_post-private-fountains.h + * @brief implementing POST /private/fountains request handling + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_POST_PRIVATE_FOUNTAINS_H +#define TALER_MERCHANT_HTTPD_POST_PRIVATE_FOUNTAINS_H + +#include "taler-merchant-httpd.h" + + +/** + * Create a fountain (DD 98). + * + * @param rh context of the handler + * @param connection the MHD connection to handle + * @param[in,out] hc context with further information about the request + * @return MHD result code + */ +enum MHD_Result +TMH_private_post_fountains (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +#endif diff --git a/src/backend/taler-merchant-httpd_post-private-orders.c b/src/backend/taler-merchant-httpd_post-private-orders.c @@ -42,6 +42,7 @@ #include "taler-merchant-httpd.h" #include "taler-merchant-httpd_exchanges.h" #include "taler-merchant-httpd_post-private-orders.h" +#include "taler-merchant-httpd_token-keys.h" #include "taler-merchant-httpd_get-exchanges.h" #include "taler-merchant-httpd_contract.h" #include "taler-merchant-httpd_helper.h" @@ -55,12 +56,9 @@ #include "merchant-database/get_missing_money_pot.h" #include "merchant-database/insert_order.h" #include "merchant-database/insert_order_lock.h" -#include "merchant-database/insert_token_family_key.h" #include "merchant-database/get_order.h" #include "merchant-database/get_order_summary.h" #include "merchant-database/get_product.h" -#include "merchant-database/get_token_family_key.h" -#include "merchant-database/update_token_family_key_expiration.h" #include "merchant-database/iterate_token_family_keys.h" #include "merchant-database/iterate_donau_instances_filtered.h" #include "merchant-database/get_otp_device.h" @@ -1418,68 +1416,6 @@ phase_salt_forgettable (struct OrderContext *oc) /* ***************** ORDER_PHASE_SERIALIZE_ORDER **************** */ /** - * Get rounded time interval. @a start is calculated by rounding - * @a ts down to the nearest multiple of @a precision. - * - * @param precision rounding precision. - * year, month, day, hour, minute are supported. - * @param ts timestamp to round - * @param[out] start start of the interval - * @return #GNUNET_OK on success, #GNUNET_SYSERR on error - */ -static enum GNUNET_GenericReturnValue -get_rounded_time_interval_down (struct GNUNET_TIME_Relative precision, - struct GNUNET_TIME_Timestamp ts, - struct GNUNET_TIME_Timestamp *start) -{ - enum GNUNET_TIME_RounderInterval ri; - - ri = GNUNET_TIME_relative_to_round_interval (precision); - if ( (GNUNET_TIME_RI_NONE == ri) && - (! GNUNET_TIME_relative_is_zero (precision)) ) - { - *start = ts; - return GNUNET_SYSERR; - } - *start = GNUNET_TIME_absolute_to_timestamp ( - GNUNET_TIME_round_down (ts.abs_time, - ri)); - return GNUNET_OK; -} - - -/** - * Get rounded time interval. @a start is calculated by rounding - * @a ts up to the nearest multiple of @a precision. - * - * @param precision rounding precision. - * year, month, day, hour, minute are supported. - * @param ts timestamp to round - * @param[out] start start of the interval - * @return #GNUNET_OK on success, #GNUNET_SYSERR on error - */ -static enum GNUNET_GenericReturnValue -get_rounded_time_interval_up (struct GNUNET_TIME_Relative precision, - struct GNUNET_TIME_Timestamp ts, - struct GNUNET_TIME_Timestamp *start) -{ - enum GNUNET_TIME_RounderInterval ri; - - ri = GNUNET_TIME_relative_to_round_interval (precision); - if ( (GNUNET_TIME_RI_NONE == ri) && - (! GNUNET_TIME_relative_is_zero (precision)) ) - { - *start = ts; - return GNUNET_SYSERR; - } - *start = GNUNET_TIME_absolute_to_timestamp ( - GNUNET_TIME_round_up (ts.abs_time, - ri)); - return GNUNET_OK; -} - - -/** * Find the family entry for the family of the given @a slug * in @a oc. * @@ -1526,74 +1462,10 @@ add_family_key (void *cls, if (NULL == family) { /* Family not yet in our contract terms, create new entry */ - struct TALER_MERCHANT_ContractTokenFamily new_family = { - .slug = GNUNET_strdup (tf->slug), - .name = GNUNET_strdup (tf->name), - .description = GNUNET_strdup (tf->description), - .description_i18n = json_incref (tf->description_i18n), - }; - - switch (tf->kind) - { - case TALER_MERCHANTDB_TFK_Subscription: - { - json_t *tdomains = json_object_get (tf->extra_data, - "trusted_domains"); - json_t *dom; - size_t i; - - new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_SUBSCRIPTION; - new_family.critical = true; - new_family.details.subscription.trusted_domains_len - = json_array_size (tdomains); - GNUNET_assert (new_family.details.subscription.trusted_domains_len - < UINT_MAX); - new_family.details.subscription.trusted_domains - = GNUNET_new_array ( - new_family.details.subscription.trusted_domains_len, - char *); - json_array_foreach (tdomains, i, dom) - { - const char *val; + struct TALER_MERCHANT_ContractTokenFamily new_family; - val = json_string_value (dom); - GNUNET_break (NULL != val); - if (NULL != val) - new_family.details.subscription.trusted_domains[i] - = GNUNET_strdup (val); - } - break; - } - case TALER_MERCHANTDB_TFK_Discount: - { - json_t *edomains = json_object_get (tf->extra_data, - "expected_domains"); - json_t *dom; - size_t i; - - new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_DISCOUNT; - new_family.critical = false; - new_family.details.discount.expected_domains_len - = json_array_size (edomains); - GNUNET_assert (new_family.details.discount.expected_domains_len - < UINT_MAX); - new_family.details.discount.expected_domains - = GNUNET_new_array ( - new_family.details.discount.expected_domains_len, - char *); - json_array_foreach (edomains, i, dom) - { - const char *val; - - val = json_string_value (dom); - GNUNET_break (NULL != val); - if (NULL != val) - new_family.details.discount.expected_domains[i] - = GNUNET_strdup (val); - } - break; - } - } + TMH_token_family_to_contract (tf, + &new_family); GNUNET_array_append (oc->parse_choices.token_families, oc->parse_choices.token_families_len, new_family); @@ -1717,48 +1589,6 @@ find_key_index (struct TALER_MERCHANT_ContractTokenFamily *family, /** - * Create fresh key pair based on @a cipher_spec. - * - * @param cipher_spec which kind of key pair should we generate - * @param[out] priv set to new private key - * @param[out] pub set to new public key - * @return #GNUNET_OK on success - */ -static enum GNUNET_GenericReturnValue -create_key (const char *cipher_spec, - struct TALER_TokenIssuePrivateKey *priv, - struct TALER_TokenIssuePublicKey *pub) -{ - unsigned int len; - char dummy; - - if (0 == strcmp ("cs", - cipher_spec)) - { - GNUNET_CRYPTO_blind_sign_keys_create ( - &priv->private_key, - &pub->public_key, - GNUNET_CRYPTO_BSA_CS); - return GNUNET_OK; - } - if (1 == - sscanf (cipher_spec, - "rsa(%u)%c", - &len, - &dummy)) - { - GNUNET_CRYPTO_blind_sign_keys_create ( - &priv->private_key, - &pub->public_key, - GNUNET_CRYPTO_BSA_RSA, - len); - return GNUNET_OK; - } - return GNUNET_SYSERR; -} - - -/** * Check if the token family with the given @a slug is already present in the * list of token families for this order. If not, fetch its details and add it * to the list. Also checks if there is a public key with that expires after @@ -1782,43 +1612,9 @@ add_output_token_family (struct OrderContext *oc, { struct TALER_MERCHANTDB_TokenFamilyKeyDetails key_details; struct TALER_MERCHANT_ContractTokenFamily *family; - enum GNUNET_DB_QueryStatus qs; + enum GNUNET_GenericReturnValue res; + bool minted; - /* We are about to promise a token of this family, so the private key - covering @a valid_at must survive until we sign at the pay deadline. If - an existing key covers the validity period but was minted for an order - with an earlier pay deadline, extend its lifetime instead of minting a - second key for the very same validity period: the key lookups below (and - find_key_index()) would otherwise consider that key missing and we would - end up listing two keys for one validity period in the contract terms. */ - qs = TALER_MERCHANTDB_update_token_family_key_expiration ( - TMH_db, - oc->hc->instance->settings.id, - slug, - valid_at, - oc->parse_order.order->pay_deadline); - switch (qs) - { - case GNUNET_DB_STATUS_HARD_ERROR: - GNUNET_break (0); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_STORE_FAILED, - "update_token_family_key_expiration"); - return GNUNET_SYSERR; - case GNUNET_DB_STATUS_SOFT_ERROR: - /* Single-statement transaction shouldn't possibly cause serialization errors. - Thus treating like a hard error. */ - GNUNET_break (0); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_SOFT_FAILURE, - "update_token_family_key_expiration"); - return GNUNET_SYSERR; - default: - /* No key needed extending, or one/more were extended; either is fine. */ - break; - } family = find_family (oc, slug); if ( (NULL != family) && @@ -1826,243 +1622,55 @@ add_output_token_family (struct OrderContext *oc, find_key_index (family, valid_at, key_index)) ) + { + /* Even a key already in the contract must remain usable until + this order's payment deadline. */ + res = TMH_token_key_extend (oc->connection, + oc->hc->instance->settings.id, + slug, + valid_at, + oc->parse_order.order->pay_deadline); + if (GNUNET_OK != res) + { + finalize_order2 (oc, + res); + return GNUNET_SYSERR; + } return GNUNET_OK; - qs = TALER_MERCHANTDB_get_token_family_key ( - TMH_db, - oc->hc->instance->settings.id, - slug, - valid_at, - oc->parse_order.order->pay_deadline, - &key_details); - switch (qs) + } + res = TMH_token_key_ensure (oc->connection, + oc->hc->instance->settings.id, + slug, + valid_at, + oc->parse_order.order->pay_deadline, + &key_details, + &minted); + if (GNUNET_OK != res) { - case GNUNET_DB_STATUS_HARD_ERROR: - GNUNET_break (0); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_FETCH_FAILED, - "get_token_family_key"); - return GNUNET_SYSERR; - case GNUNET_DB_STATUS_SOFT_ERROR: - /* Single-statement transaction shouldn't possibly cause serialization errors. - Thus treating like a hard error. */ - GNUNET_break (0); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_SOFT_FAILURE, - "get_token_family_key"); - return GNUNET_SYSERR; - case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: - GNUNET_log (GNUNET_ERROR_TYPE_WARNING, - "Output token family slug %s unknown at %llu for %llu for instance %s\n", - slug, - (unsigned long long) valid_at.abs_time.abs_value_us, - (unsigned long long) oc->parse_order.order->pay_deadline.abs_time.abs_value_us, - oc->hc->instance->settings.id); - reply_with_error (oc, - MHD_HTTP_NOT_FOUND, - TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN, - slug); + finalize_order2 (oc, + res); return GNUNET_SYSERR; - case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: - break; } - - GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "Lookup of token family %s at %llu yielded %s\n", - slug, - (unsigned long long) valid_at.abs_time.abs_value_us, - NULL == key_details.pub.public_key ? "no key" : "a key"); - - /* add_family_key() must run even if the family already exists, else a - DB-only key would never reach the in-memory family and the - find_key_index() assertion below aborts the backend (SIGABRT). */ + /* Add the result even if the family is already in the contract: + the database may have returned a key not yet listed there. */ add_family_key (oc, &key_details); - if (NULL == family) + TMH_token_key_details_free (&key_details); + family = find_family (oc, + slug); + GNUNET_assert (NULL != family); + if (minted) { - family = find_family (oc, - slug); - GNUNET_assert (NULL != family); + /* Preserve the order path's choice of the newly appended key, + even if an older key covers an overlapping validity period. */ + *key_index = family->keys_len - 1; } - /* we don't need the full family details anymore */ - GNUNET_free (key_details.token_family.slug); - GNUNET_free (key_details.token_family.name); - GNUNET_free (key_details.token_family.description); - json_decref (key_details.token_family.description_i18n); - json_decref (key_details.token_family.extra_data); - - if (NULL != key_details.pub.public_key) + else { - /* get_token_family_key must have found a matching key, - and it must have been added. Find and use the index. */ - GNUNET_CRYPTO_blind_sign_pub_decref (key_details.pub.public_key); - GNUNET_CRYPTO_blind_sign_priv_decref (key_details.priv.private_key); - GNUNET_free (key_details.token_family.cipher_spec); GNUNET_assert (GNUNET_OK == find_key_index (family, valid_at, key_index)); - return GNUNET_OK; - } - - /* No suitable key exists, create one! */ - { - struct TALER_MERCHANT_ContractTokenFamilyKey key; - enum GNUNET_DB_QueryStatus iqs; - struct TALER_TokenIssuePrivateKey token_priv; - struct GNUNET_TIME_Timestamp key_expires; - struct GNUNET_TIME_Timestamp round_start; - - if (GNUNET_OK != - get_rounded_time_interval_down ( - key_details.token_family.validity_granularity, - GNUNET_TIME_absolute_to_timestamp ( - GNUNET_TIME_absolute_subtract ( - valid_at.abs_time, - key_details.token_family.start_offset)), - &round_start)) - { - GNUNET_break (0); - GNUNET_log (GNUNET_ERROR_TYPE_ERROR, - "Unsupported validity granularity interval %s found in database for token family %s!\n", - GNUNET_TIME_relative2s ( - key_details.token_family.validity_granularity, - false), - slug); - GNUNET_free (key_details.token_family.cipher_spec); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, - "get_rounded_time_interval_down failed"); - return GNUNET_SYSERR; - } - if (GNUNET_TIME_relative_cmp ( - key_details.token_family.duration, - <, - GNUNET_TIME_relative_add ( - key_details.token_family.validity_granularity, - key_details.token_family.start_offset))) - { - GNUNET_break (0); - GNUNET_log (GNUNET_ERROR_TYPE_ERROR, - "Inconsistent duration %s found in database for token family %s (below validity granularity plus start_offset)!\n", - GNUNET_TIME_relative2s (key_details.token_family.duration, - false), - slug); - GNUNET_free (key_details.token_family.cipher_spec); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, - "duration, validity_granularity and start_offset inconsistent for token family"); - return GNUNET_SYSERR; - } - key.valid_after - = GNUNET_TIME_timestamp_max ( - GNUNET_TIME_absolute_to_timestamp ( - GNUNET_TIME_absolute_subtract ( - round_start.abs_time, - key_details.token_family.start_offset)), - key_details.token_family.valid_after); - key.valid_before - = GNUNET_TIME_timestamp_min ( - GNUNET_TIME_absolute_to_timestamp ( - GNUNET_TIME_absolute_add ( - key.valid_after.abs_time, - key_details.token_family.duration)), - key_details.token_family.valid_before); - GNUNET_assert (GNUNET_OK == - get_rounded_time_interval_down ( - key_details.token_family.validity_granularity, - key.valid_before, - &key_expires)); - /* Make sure key never expires before the payment deadline */ - key_expires = GNUNET_TIME_timestamp_max ( - oc->parse_order.order->pay_deadline, - key_expires); - if (GNUNET_TIME_timestamp_cmp ( - key_expires, - ==, - round_start)) - { - /* valid_before does not actually end after the - next rounded validity period would start; - determine next rounded validity period - start point and extend valid_before to cover - the full validity period */ - GNUNET_assert ( - GNUNET_OK == - get_rounded_time_interval_up ( - key_details.token_family.validity_granularity, - key.valid_before, - &key_expires)); - /* This should basically always end up being key_expires */ - key.valid_before = GNUNET_TIME_timestamp_max (key.valid_before, - key_expires); - } - if (GNUNET_OK != - create_key (key_details.token_family.cipher_spec, - &token_priv, - &key.pub)) - { - GNUNET_break (0); - GNUNET_log (GNUNET_ERROR_TYPE_ERROR, - "Unsupported cipher family %s found in database for token family %s!\n", - key_details.token_family.cipher_spec, - slug); - GNUNET_free (key_details.token_family.cipher_spec); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, - "invalid cipher stored in local database for token family"); - return GNUNET_SYSERR; - } - GNUNET_free (key_details.token_family.cipher_spec); - GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "Storing new key for slug %s of %s\n", - slug, - oc->hc->instance->settings.id); - iqs = TALER_MERCHANTDB_insert_token_family_key (TMH_db, - oc->hc->instance->settings.id, - slug, - &key.pub, - &token_priv, - key_expires, - key.valid_after, - key.valid_before); - GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key); - switch (iqs) - { - case GNUNET_DB_STATUS_HARD_ERROR: - GNUNET_break (0); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_STORE_FAILED, - NULL); - return GNUNET_SYSERR; - case GNUNET_DB_STATUS_SOFT_ERROR: - /* Single-statement transaction shouldn't possibly cause serialization errors. - Thus treating like a hard error. */ - GNUNET_break (0); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_SOFT_FAILURE, - NULL); - return GNUNET_SYSERR; - case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: - GNUNET_break (0); - reply_with_error (oc, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_STORE_FAILED, - NULL); - return GNUNET_SYSERR; - case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: - break; - } - *key_index = family->keys_len; - GNUNET_array_append (family->keys, - family->keys_len, - key); } return GNUNET_OK; } diff --git a/src/backend/taler-merchant-httpd_token-keys.c b/src/backend/taler-merchant-httpd_token-keys.c @@ -0,0 +1,833 @@ +/* + This file is part of TALER + Copyright (C) 2024-2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/backend/taler-merchant-httpd_token-keys.c + * @brief shared token family key lookup, lifetime extension and creation + * for orders and fountains; also provides fountain key windows + * @author Christian Blättler + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_token-keys.h" +#include "merchant-database/insert_token_family_key.h" +#include "merchant-database/update_token_family_key_expiration.h" +#include "merchant-database/start.h" + + +/** + * Get rounded time interval. @a start is calculated by rounding + * @a ts down to the nearest multiple of @a precision. + * + * @param precision rounding precision. + * year, month, day, hour, minute are supported. + * @param ts timestamp to round + * @param[out] start start of the interval + * @return #GNUNET_OK on success, #GNUNET_SYSERR on error + */ +static enum GNUNET_GenericReturnValue +get_rounded_time_interval_down (struct GNUNET_TIME_Relative precision, + struct GNUNET_TIME_Timestamp ts, + struct GNUNET_TIME_Timestamp *start) +{ + enum GNUNET_TIME_RounderInterval ri; + + ri = GNUNET_TIME_relative_to_round_interval (precision); + if ( (GNUNET_TIME_RI_NONE == ri) && + (! GNUNET_TIME_relative_is_zero (precision)) ) + { + *start = ts; + return GNUNET_SYSERR; + } + *start = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_round_down (ts.abs_time, + ri)); + return GNUNET_OK; +} + + +/** + * Get rounded time interval. @a start is calculated by rounding + * @a ts up to the nearest multiple of @a precision. + * + * @param precision rounding precision. + * year, month, day, hour, minute are supported. + * @param ts timestamp to round + * @param[out] start start of the interval + * @return #GNUNET_OK on success, #GNUNET_SYSERR on error + */ +static enum GNUNET_GenericReturnValue +get_rounded_time_interval_up (struct GNUNET_TIME_Relative precision, + struct GNUNET_TIME_Timestamp ts, + struct GNUNET_TIME_Timestamp *start) +{ + enum GNUNET_TIME_RounderInterval ri; + + ri = GNUNET_TIME_relative_to_round_interval (precision); + if ( (GNUNET_TIME_RI_NONE == ri) && + (! GNUNET_TIME_relative_is_zero (precision)) ) + { + *start = ts; + return GNUNET_SYSERR; + } + *start = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_round_up (ts.abs_time, + ri)); + return GNUNET_OK; +} + + +/** + * Create fresh key pair based on @a cipher_spec. + * + * @param cipher_spec which kind of key pair should we generate + * @param[out] priv set to new private key + * @param[out] pub set to new public key + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +create_key (const char *cipher_spec, + struct TALER_TokenIssuePrivateKey *priv, + struct TALER_TokenIssuePublicKey *pub) +{ + unsigned int len; + char dummy; + + if (0 == strcmp ("cs", + cipher_spec)) + { + GNUNET_CRYPTO_blind_sign_keys_create ( + &priv->private_key, + &pub->public_key, + GNUNET_CRYPTO_BSA_CS); + return GNUNET_OK; + } + if (1 == + sscanf (cipher_spec, + "rsa(%u)%c", + &len, + &dummy)) + { + GNUNET_CRYPTO_blind_sign_keys_create ( + &priv->private_key, + &pub->public_key, + GNUNET_CRYPTO_BSA_RSA, + len); + return GNUNET_OK; + } + return GNUNET_SYSERR; +} + + +void +TMH_token_key_details_free ( + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details) +{ + GNUNET_free (key_details->token_family.slug); + GNUNET_free (key_details->token_family.name); + GNUNET_free (key_details->token_family.description); + json_decref (key_details->token_family.description_i18n); + key_details->token_family.description_i18n = NULL; + json_decref (key_details->token_family.extra_data); + key_details->token_family.extra_data = NULL; + GNUNET_free (key_details->token_family.cipher_spec); + if (NULL != key_details->pub.public_key) + { + GNUNET_CRYPTO_blind_sign_pub_decref (key_details->pub.public_key); + key_details->pub.public_key = NULL; + } + if (NULL != key_details->priv.private_key) + { + GNUNET_CRYPTO_blind_sign_priv_decref (key_details->priv.private_key); + key_details->priv.private_key = NULL; + } +} + + +/** + * Create, store and return a fresh issue key covering @a valid_at + * for the token family described by @a key_details. + * + * @param connection connection to report errors on + * @param instance_id instance owning the token family + * @param slug slug of the token family + * @param valid_at time the new key must cover + * @param sign_until how long the private key must remain usable + * @param require_coverage if true, leave the key unset when the existing + * validity rules cannot cover @a valid_at within the family bounds + * @param[in,out] key_details token family details on entry; the new + * key and its validity bounds are added on success + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +static enum GNUNET_GenericReturnValue +mint_key (struct MHD_Connection *connection, + const char *instance_id, + const char *slug, + struct GNUNET_TIME_Timestamp valid_at, + struct GNUNET_TIME_Timestamp sign_until, + bool require_coverage, + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details) +{ + struct TALER_MERCHANT_ContractTokenFamilyKey key; + enum GNUNET_DB_QueryStatus iqs; + struct TALER_TokenIssuePrivateKey token_priv; + struct GNUNET_TIME_Timestamp key_expires; + struct GNUNET_TIME_Timestamp round_start; + + /* Offset the rounded period once. Subtracting the offset before rounding + as well can select an already expired period, even when duration is at + least granularity + offset. Existing covering keys are reused by lookup. */ + if (GNUNET_OK != + get_rounded_time_interval_down ( + key_details->token_family.validity_granularity, + valid_at, + &round_start)) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unsupported validity granularity interval %s found in database for token family %s!\n", + GNUNET_TIME_relative2s ( + key_details->token_family.validity_granularity, + false), + slug); + TMH_token_key_details_free (key_details); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "get_rounded_time_interval_down failed")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (GNUNET_TIME_relative_cmp ( + key_details->token_family.duration, + <, + GNUNET_TIME_relative_add ( + key_details->token_family.validity_granularity, + key_details->token_family.start_offset))) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Inconsistent duration %s found in database for token family %s (below validity granularity plus start_offset)!\n", + GNUNET_TIME_relative2s (key_details->token_family.duration, + false), + slug); + TMH_token_key_details_free (key_details); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "duration, validity_granularity and start_offset inconsistent for token family")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + key.valid_after + = GNUNET_TIME_timestamp_max ( + GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_subtract ( + round_start.abs_time, + key_details->token_family.start_offset)), + key_details->token_family.valid_after); + key.valid_before + = GNUNET_TIME_timestamp_min ( + GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add ( + key.valid_after.abs_time, + key_details->token_family.duration)), + key_details->token_family.valid_before); + GNUNET_assert (GNUNET_OK == + get_rounded_time_interval_down ( + key_details->token_family.validity_granularity, + key.valid_before, + &key_expires)); + /* Make sure key never expires before @a sign_until */ + key_expires = GNUNET_TIME_timestamp_max ( + sign_until, + key_expires); + if (GNUNET_TIME_timestamp_cmp ( + key_expires, + ==, + round_start)) + { + /* valid_before does not actually end after the + next rounded validity period would start; + determine next rounded validity period + start point and extend valid_before to cover + the full validity period */ + GNUNET_assert ( + GNUNET_OK == + get_rounded_time_interval_up ( + key_details->token_family.validity_granularity, + key.valid_before, + &key_expires)); + /* This should basically always end up being key_expires */ + key.valid_before = GNUNET_TIME_timestamp_max (key.valid_before, + key_expires); + } + /* Fountains must not advertise a key that fails to advance the + coverage chain. Check before generating or storing anything. The + order path retains its existing validity semantics. */ + if (require_coverage && + (GNUNET_TIME_timestamp_cmp (key.valid_after, >, valid_at) || + GNUNET_TIME_timestamp_cmp (key.valid_before, <, valid_at) || + GNUNET_TIME_timestamp_cmp (key.valid_before, + >, + key_details->token_family.valid_before))) + return GNUNET_OK; + if (GNUNET_OK != + create_key (key_details->token_family.cipher_spec, + &token_priv, + &key.pub)) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unsupported cipher family %s found in database for token family %s!\n", + key_details->token_family.cipher_spec, + slug); + TMH_token_key_details_free (key_details); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "invalid cipher stored in local database for token family")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Storing new key for slug %s of %s\n", + slug, + instance_id); + iqs = TALER_MERCHANTDB_insert_token_family_key (TMH_db, + instance_id, + slug, + &key.pub, + &token_priv, + key_expires, + key.valid_after, + key.valid_before); + switch (iqs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key); + GNUNET_CRYPTO_blind_sign_pub_decref (key.pub.public_key); + TMH_token_key_details_free (key_details); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + NULL)) + ? GNUNET_NO + : GNUNET_SYSERR; + case GNUNET_DB_STATUS_SOFT_ERROR: + /* Report the conflict without advertising an uncommitted key. */ + GNUNET_break (0); + GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key); + GNUNET_CRYPTO_blind_sign_pub_decref (key.pub.public_key); + TMH_token_key_details_free (key_details); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + NULL)) + ? GNUNET_NO + : GNUNET_SYSERR; + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_break (0); + GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key); + GNUNET_CRYPTO_blind_sign_pub_decref (key.pub.public_key); + TMH_token_key_details_free (key_details); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + NULL)) + ? GNUNET_NO + : GNUNET_SYSERR; + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + } + key_details->pub = key.pub; + key_details->priv = token_priv; + key_details->signature_validity_start = key.valid_after; + key_details->signature_validity_end = key.valid_before; + key_details->private_key_deleted_at = key_expires; + return GNUNET_OK; +} + + +enum GNUNET_GenericReturnValue +TMH_token_key_extend ( + struct MHD_Connection *connection, + const char *instance_id, + const char *slug, + struct GNUNET_TIME_Timestamp valid_at, + struct GNUNET_TIME_Timestamp sign_until) +{ + enum GNUNET_DB_QueryStatus qs; + + /* We are about to promise a token of this family, so the private key + covering @a valid_at must survive until we sign at @a sign_until. If + an existing key covers the validity period but was minted for an order + with an earlier pay deadline, extend its lifetime instead of minting a + second key for the very same validity period: the key lookup below + would otherwise consider that key missing and we would end up with two + keys for one validity period. */ + qs = TALER_MERCHANTDB_update_token_family_key_expiration ( + TMH_db, + instance_id, + slug, + valid_at, + sign_until); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "update_token_family_key_expiration")) + ? GNUNET_NO + : GNUNET_SYSERR; + case GNUNET_DB_STATUS_SOFT_ERROR: + /* Report the conflict without advertising an uncommitted key. */ + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + "update_token_family_key_expiration")) + ? GNUNET_NO + : GNUNET_SYSERR; + default: + /* No key needed extending, or one/more were extended; either is fine. */ + break; + } + return GNUNET_OK; +} + + +/** + * Implementation of #TMH_token_key_ensure(). With @a require_coverage, + * success may leave the key unset when rounding would fail to cover the + * requested time. This allows fountains to stop without storing unusable + * keys; the caller must still free the returned family details. The caller + * must hold the token family lock throughout this operation. + */ +static enum GNUNET_GenericReturnValue +ensure_key_locked ( + struct MHD_Connection *connection, + const char *instance_id, + const char *slug, + struct GNUNET_TIME_Timestamp valid_at, + struct GNUNET_TIME_Timestamp sign_until, + bool require_coverage, + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details, + bool *minted) +{ + enum GNUNET_DB_QueryStatus qs; + enum GNUNET_GenericReturnValue res; + + *minted = false; + res = TMH_token_key_extend (connection, + instance_id, + slug, + valid_at, + sign_until); + if (GNUNET_OK != res) + return res; + qs = TALER_MERCHANTDB_get_token_family_key ( + TMH_db, + instance_id, + slug, + valid_at, + sign_until, + key_details); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_token_family_key")) + ? GNUNET_NO + : GNUNET_SYSERR; + case GNUNET_DB_STATUS_SOFT_ERROR: + /* Report the conflict without advertising an uncommitted key. */ + GNUNET_break (0); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + "get_token_family_key")) + ? GNUNET_NO + : GNUNET_SYSERR; + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Token family slug %s unknown at %llu for %llu for instance %s\n", + slug, + (unsigned long long) valid_at.abs_time.abs_value_us, + (unsigned long long) sign_until.abs_time.abs_value_us, + instance_id); + return (MHD_YES == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN, + slug)) + ? GNUNET_NO + : GNUNET_SYSERR; + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + } + + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Lookup of token family %s at %llu yielded %s\n", + slug, + (unsigned long long) valid_at.abs_time.abs_value_us, + NULL == key_details->pub.public_key ? "no key" : "a key"); + + if (NULL != key_details->pub.public_key) + return GNUNET_OK; + + /* No suitable key exists, create one! */ + { + res = mint_key (connection, + instance_id, + slug, + valid_at, + sign_until, + require_coverage, + key_details); + if (GNUNET_OK != res) + return res; + *minted = (NULL != key_details->pub.public_key); + } + return GNUNET_OK; +} + + +/** + * Hold the family lock across the entire lookup-and-mint operation. Only + * commit transactions we started; withdrawal keeps the lock until its quota + * and signatures are committed together. + */ +static enum GNUNET_GenericReturnValue +ensure_key ( + struct MHD_Connection *connection, + const char *instance_id, + const char *slug, + struct GNUNET_TIME_Timestamp valid_at, + struct GNUNET_TIME_Timestamp sign_until, + bool require_coverage, + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details, + bool *minted) +{ + bool started_transaction; + enum GNUNET_DB_QueryStatus qs; + enum GNUNET_GenericReturnValue res; + + *minted = false; + qs = TALER_MERCHANTDB_lock_token_family (TMH_db, + slug, + &started_transaction); + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs) + { + if (started_transaction) + TALER_MERCHANTDB_rollback (TMH_db); + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN, + slug)) + ? GNUNET_NO + : GNUNET_SYSERR; + } + if (qs < 0) + { + if (started_transaction) + TALER_MERCHANTDB_rollback (TMH_db); + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + GNUNET_DB_STATUS_SOFT_ERROR == qs + ? TALER_EC_GENERIC_DB_SOFT_FAILURE + : TALER_EC_GENERIC_DB_STORE_FAILED, + "lock_token_family")) + ? GNUNET_NO + : GNUNET_SYSERR; + } + res = ensure_key_locked (connection, + instance_id, + slug, + valid_at, + sign_until, + require_coverage, + key_details, + minted); + if (! started_transaction) + return res; + if (GNUNET_OK != res) + { + TALER_MERCHANTDB_rollback (TMH_db); + return res; + } + qs = TALER_MERCHANTDB_commit (TMH_db); + if (qs >= 0) + return GNUNET_OK; + TMH_token_key_details_free (key_details); + *minted = false; + return (MHD_YES == + TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + GNUNET_DB_STATUS_SOFT_ERROR == qs + ? TALER_EC_GENERIC_DB_SOFT_FAILURE + : TALER_EC_GENERIC_DB_COMMIT_FAILED, + "ensure token family key")) + ? GNUNET_NO + : GNUNET_SYSERR; +} + + +enum GNUNET_GenericReturnValue +TMH_token_key_ensure ( + struct MHD_Connection *connection, + const char *instance_id, + const char *slug, + struct GNUNET_TIME_Timestamp valid_at, + struct GNUNET_TIME_Timestamp sign_until, + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details, + bool *minted) +{ + return ensure_key (connection, + instance_id, + slug, + valid_at, + sign_until, + false, + key_details, + minted); +} + + +void +TMH_token_key_window_free (struct TMH_TokenKeyWindow *window) +{ + for (unsigned int i = 0; i < window->keys_len; i++) + TMH_token_key_details_free (&window->keys[i]); + GNUNET_array_grow (window->keys, + window->keys_len, + 0); +} + + +enum GNUNET_GenericReturnValue +TMH_token_key_window_get ( + struct MHD_Connection *connection, + const char *instance_id, + const struct TALER_MERCHANTDB_TokenFamilyDetails *tf, + struct GNUNET_TIME_Timestamp now, + unsigned int key_window_size, + struct TMH_TokenKeyWindow *window) +{ + struct GNUNET_TIME_Timestamp cursor; + struct GNUNET_TIME_Timestamp latest_start; + + *window = (struct TMH_TokenKeyWindow) {0}; + GNUNET_assert (key_window_size <= TMH_MAX_FOUNTAIN_KEY_WINDOW); + latest_start = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add ( + now.abs_time, + GNUNET_TIME_relative_multiply (tf->duration, + key_window_size))); + cursor = GNUNET_TIME_timestamp_max (now, + tf->valid_after); + /* A future family must fit the horizon measured from now; its start + must never move that horizon forward. */ + if (GNUNET_TIME_timestamp_cmp (cursor, >, latest_start)) + return GNUNET_OK; + for (unsigned int slot = 0; slot <= key_window_size; slot++) + { + struct TALER_MERCHANTDB_TokenFamilyKeyDetails kd; + struct GNUNET_TIME_Timestamp next; + enum GNUNET_GenericReturnValue res; + bool minted; + + if (GNUNET_TIME_timestamp_cmp (cursor, >=, tf->valid_before)) + break; + res = ensure_key (connection, + instance_id, + tf->slug, + cursor, + now, + true, + &kd, + &minted); + if (GNUNET_OK != res) + { + TMH_token_key_window_free (window); + return res; + } + if ( (NULL == kd.pub.public_key) || + GNUNET_TIME_timestamp_cmp (kd.signature_validity_start, >, latest_start) || + GNUNET_TIME_timestamp_cmp (kd.signature_validity_start, >, cursor) || + GNUNET_TIME_timestamp_cmp (kd.signature_validity_end, <, cursor) || + GNUNET_TIME_timestamp_cmp (kd.signature_validity_end, + >, + tf->valid_before) ) + { + /* Rounding/offsets may not yield further coverage. Do not count + a repeated or unusable period as another future key. */ + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Token family %s cannot extend fountain coverage at %llu\n", + tf->slug, + (unsigned long long) cursor.abs_time.abs_value_us); + TMH_token_key_details_free (&kd); + break; + } + GNUNET_array_append (window->keys, + window->keys_len, + kd); + /* Lookup includes the expiry instant. Move one whole timestamp + tick beyond it, so the previous key cannot be selected again. */ + next = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (kd.signature_validity_end.abs_time, + GNUNET_TIME_UNIT_SECONDS)); + next = GNUNET_TIME_timestamp_min (next, + latest_start); + if (GNUNET_TIME_timestamp_cmp (next, <=, kd.signature_validity_end)) + break; /* reached the horizon, forever or saturated arithmetic */ + cursor = next; + } + return GNUNET_OK; +} + + +enum GNUNET_GenericReturnValue +TMH_token_key_window_find ( + const struct TMH_TokenKeyWindow *window, + struct GNUNET_TIME_Timestamp valid_at, + unsigned int *key_index) +{ + /* Wallets can select an advertised key unambiguously by its start, + even if an earlier key also covers that instant. */ + for (unsigned int i = 0; i < window->keys_len; i++) + if (GNUNET_TIME_timestamp_cmp (valid_at, + ==, + window->keys[i].signature_validity_start)) + { + *key_index = i; + return GNUNET_OK; + } + /* Otherwise keep the covering-key convention: first matching key. */ + for (unsigned int i = 0; i < window->keys_len; i++) + if (GNUNET_TIME_timestamp_cmp (valid_at, + >=, + window->keys[i].signature_validity_start) && + GNUNET_TIME_timestamp_cmp (valid_at, + <=, + window->keys[i].signature_validity_end)) + { + *key_index = i; + return GNUNET_OK; + } + return GNUNET_NO; +} + + +void +TMH_token_family_to_contract ( + const struct TALER_MERCHANTDB_TokenFamilyDetails *tf, + struct TALER_MERCHANT_ContractTokenFamily *family) +{ + struct TALER_MERCHANT_ContractTokenFamily new_family = { + .slug = GNUNET_strdup (tf->slug), + .name = GNUNET_strdup (tf->name), + .description = GNUNET_strdup (tf->description), + .description_i18n = json_incref (tf->description_i18n), + }; + + switch (tf->kind) + { + case TALER_MERCHANTDB_TFK_Subscription: + { + json_t *tdomains = json_object_get (tf->extra_data, + "trusted_domains"); + json_t *dom; + size_t i; + + new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_SUBSCRIPTION; + new_family.critical = true; + new_family.details.subscription.trusted_domains_len + = json_array_size (tdomains); + GNUNET_assert (new_family.details.subscription.trusted_domains_len + < UINT_MAX); + new_family.details.subscription.trusted_domains + = GNUNET_new_array ( + new_family.details.subscription.trusted_domains_len, + char *); + json_array_foreach (tdomains, i, dom) + { + const char *val; + + val = json_string_value (dom); + GNUNET_break (NULL != val); + if (NULL != val) + new_family.details.subscription.trusted_domains[i] + = GNUNET_strdup (val); + } + break; + } + case TALER_MERCHANTDB_TFK_Discount: + { + json_t *edomains = json_object_get (tf->extra_data, + "expected_domains"); + json_t *dom; + size_t i; + + new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_DISCOUNT; + new_family.critical = false; + new_family.details.discount.expected_domains_len + = json_array_size (edomains); + GNUNET_assert (new_family.details.discount.expected_domains_len + < UINT_MAX); + new_family.details.discount.expected_domains + = GNUNET_new_array ( + new_family.details.discount.expected_domains_len, + char *); + json_array_foreach (edomains, i, dom) + { + const char *val; + + val = json_string_value (dom); + GNUNET_break (NULL != val); + if (NULL != val) + new_family.details.discount.expected_domains[i] + = GNUNET_strdup (val); + } + break; + } + } + *family = new_family; +} + + +/* end of taler-merchant-httpd_token-keys.c */ diff --git a/src/backend/taler-merchant-httpd_token-keys.h b/src/backend/taler-merchant-httpd_token-keys.h @@ -0,0 +1,198 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/backend/taler-merchant-httpd_token-keys.h + * @brief shared token family key lookup, lifetime extension and creation + * for orders and fountains; also provides fountain key windows + * @author Christian Blättler + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_TOKEN_KEYS_H +#define TALER_MERCHANT_HTTPD_TOKEN_KEYS_H + +#include "taler-merchant-httpd.h" +#include "taler/taler_merchant_util.h" +#include "merchantdb_lib.h" +#include "merchant-database/get_token_family_key.h" + +/** + * Hard cap on the ``key_window_size`` of a fountain grant. Every + * advertised future slot may require minting (and storing) a fresh + * blind-signing key pair on ``GET /fountain/info``, so an unbounded + * window would be a denial-of-service vector. + */ +#define TMH_MAX_FOUNTAIN_KEY_WINDOW 12 + +/** + * Hard cap on the total number of token envelopes in one + * ``POST /fountain/withdraw`` request. Matches the cap on token + * outputs of the pay endpoint. + */ +#define TMH_MAX_FOUNTAIN_ENVELOPES 64 + + +/** + * Extend the lifetime of existing private keys covering @a valid_at + * until at least @a sign_until. Does not create a key. Order creation + * also calls this when reusing a key already added to the contract. + * + * @param connection connection to report errors on + * @param instance_id instance owning the token family + * @param slug slug of the token family + * @param valid_at time at which the tokens must be valid + * @param sign_until how long the private key must remain usable + * @return #GNUNET_OK on success, #GNUNET_NO if an error response was + * queued, #GNUNET_SYSERR on hard failure + */ +enum GNUNET_GenericReturnValue +TMH_token_key_extend ( + struct MHD_Connection *connection, + const char *instance_id, + const char *slug, + struct GNUNET_TIME_Timestamp valid_at, + struct GNUNET_TIME_Timestamp sign_until); + + +/** + * Ensure that an issue key of token family @a slug covering @a valid_at + * exists and that its private key remains usable for signing until at + * least @a sign_until. If a key covering @a valid_at exists but its + * private key would be deleted earlier, the private key's lifetime is + * extended; only if no key covers @a valid_at at all is a new key pair + * generated and stored in the database. + * + * @param connection connection to report errors on + * @param instance_id instance owning the token family + * @param slug slug of the token family + * @param valid_at time at which tokens signed with the key must be valid; + * determines the key's validity period + * @param sign_until how long the private key must remain usable + * @param[out] key_details set to the key (public and private part), its + * validity bounds and the token family details; on success the + * caller must release it via #TMH_token_key_details_free() + * @param[out] minted set to true if a fresh key pair was created + * @return #GNUNET_OK on success (and only then is @a key_details set), + * #GNUNET_NO if an error response was already queued on + * @a connection, #GNUNET_SYSERR on hard failure + */ +enum GNUNET_GenericReturnValue +TMH_token_key_ensure ( + struct MHD_Connection *connection, + const char *instance_id, + const char *slug, + struct GNUNET_TIME_Timestamp valid_at, + struct GNUNET_TIME_Timestamp sign_until, + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details, + bool *minted); + + +/** + * Release all resources of @a key_details (but not the structure + * itself, which is typically stack-allocated). + * + * @param[in,out] key_details result of a successful + * #TMH_token_key_ensure() to release + */ +void +TMH_token_key_details_free ( + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details); + + +/** + * Current and future keys of a fountain grant, in order of strictly + * increasing expiry. Owns the key and family details. + */ +struct TMH_TokenKeyWindow +{ + /** Keys selected using the shared order lookup and generation logic. */ + struct TALER_MERCHANTDB_TokenFamilyKeyDetails *keys; + + /** Number of keys; at most key_window_size + 1. */ + unsigned int keys_len; +}; + + +/** + * Obtain the current key and up to @a key_window_size future keys. + * The first lookup is at max(now, family.valid_after); each subsequent + * lookup is one second beyond the previous key's actual expiry, capped + * at now + key_window_size * duration. No key may start after that + * horizon, including when the family itself starts in the future. + * Rounding can make the validity intervals overlap. Stop at the family + * expiry or when the existing validity rules cannot extend coverage. + * + * @param connection connection to report errors on + * @param instance_id instance owning the token family + * @param tf token family details + * @param now time of this request, also the private-key retention minimum + * @param key_window_size number of future keys, at most + * #TMH_MAX_FOUNTAIN_KEY_WINDOW (zero allows the current key only) + * @param[out] window initialized window; empty on error + * @return #GNUNET_OK on success (possibly an empty/shorter window), + * #GNUNET_NO if an error response was queued, + * #GNUNET_SYSERR on hard failure + */ +enum GNUNET_GenericReturnValue +TMH_token_key_window_get ( + struct MHD_Connection *connection, + const char *instance_id, + const struct TALER_MERCHANTDB_TokenFamilyDetails *tf, + struct GNUNET_TIME_Timestamp now, + unsigned int key_window_size, + struct TMH_TokenKeyWindow *window); + + +/** + * Release the window's keys and family details and reset it to empty. + * + * @param[in,out] window window to release + */ +void +TMH_token_key_window_free (struct TMH_TokenKeyWindow *window); + + +/** + * Select a key from an advertised window. An exact validity-start match + * takes precedence over overlapping earlier keys. Otherwise the first + * key covering @a valid_at is selected. A missing valid_at in the wallet + * request selects index zero directly, rather than calling this function. + * + * @param window current window of the grant + * @param valid_at requested validity time + * @param[out] key_index selected index + * @return #GNUNET_OK on match, #GNUNET_NO if outside the window + */ +enum GNUNET_GenericReturnValue +TMH_token_key_window_find ( + const struct TMH_TokenKeyWindow *window, + struct GNUNET_TIME_Timestamp valid_at, + unsigned int *key_index); + + +/** + * Convert token family details from the database into a contract + * token family (without keys). The result must be released via + * #TALER_MERCHANT_contract_token_family_free(). + * + * @param tf token family details from the database + * @param[out] family initialized contract token family + */ +void +TMH_token_family_to_contract ( + const struct TALER_MERCHANTDB_TokenFamilyDetails *tf, + struct TALER_MERCHANT_ContractTokenFamily *family); + +#endif diff --git a/src/backenddb/delete_fountain.c b/src/backenddb/delete_fountain.c @@ -0,0 +1,47 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/delete_fountain.c + * @brief Implementation of the delete_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/delete_fountain.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_delete_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (fountain_id), + GNUNET_PQ_query_param_end + }; + + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "DELETE FROM merchant_fountains" + " WHERE fountain_id=$1"); + return GNUNET_PQ_eval_prepared_non_select (pg->conn, + "", + params); +} diff --git a/src/backenddb/do_fountain_withdraw.c b/src/backenddb/do_fountain_withdraw.c @@ -0,0 +1,127 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/do_fountain_withdraw.c + * @brief Implementation of the do_fountain_withdraw function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/do_fountain_withdraw.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_do_fountain_withdraw ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + uint64_t fountain_serial, + unsigned int num_entries, + const uint64_t token_family_serials[static num_entries], + const struct GNUNET_TIME_Timestamp slot_starts[static num_entries], + const uint64_t num_requested[static num_entries], + unsigned int *failed_index, + bool *no_grant, + bool *exceeded) +{ + unsigned int order[GNUNET_NZL (num_entries)]; + uint64_t s_families[GNUNET_NZL (num_entries)]; + uint64_t s_slots[GNUNET_NZL (num_entries)]; + uint64_t s_counts[GNUNET_NZL (num_entries)]; + uint32_t sql_failed_index; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_uint64 (&fountain_serial), + GNUNET_PQ_query_param_array_uint64 (num_entries, + s_families, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (num_entries, + s_slots, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (num_entries, + s_counts, + pg->conn), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint32 ("out_failed_index", + &sql_failed_index), + GNUNET_PQ_result_spec_bool ("out_no_grant", + no_grant), + GNUNET_PQ_result_spec_bool ("out_exceeded", + exceeded), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; + + *failed_index = num_entries; + *no_grant = false; + *exceeded = false; + /* Sort entries by (token_family_serial, slot_start) so that + concurrent withdrawals acquire the counter row locks in a + deterministic order and cannot deadlock. Insertion sort: the + number of entries is bounded by the request size cap. */ + for (unsigned int i = 0; i < num_entries; i++) + order[i] = i; + for (unsigned int i = 1; i < num_entries; i++) + { + unsigned int cur = order[i]; + unsigned int j = i; + + while ( (j > 0) && + ( (token_family_serials[order[j - 1]] > + token_family_serials[cur]) || + ( (token_family_serials[order[j - 1]] == + token_family_serials[cur]) && + (slot_starts[order[j - 1]].abs_time.abs_value_us > + slot_starts[cur].abs_time.abs_value_us) ) ) ) + { + order[j] = order[j - 1]; + j--; + } + order[j] = cur; + } + for (unsigned int i = 0; i < num_entries; i++) + { + s_families[i] = token_family_serials[order[i]]; + s_slots[i] = slot_starts[order[i]].abs_time.abs_value_us; + s_counts[i] = num_requested[order[i]]; + } + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "SELECT" + " out_failed_index" + " ,out_no_grant" + " ,out_exceeded" + " FROM merchant_do_fountain_withdraw" + " ($1, $2, $3, $4);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); + GNUNET_PQ_cleanup_query_params_closures (params); + if (qs < 0) + return qs; + if (0 != sql_failed_index) + { + /* Map the 1-based index into the sorted arrays back to the + caller's entry order. */ + GNUNET_assert (sql_failed_index <= num_entries); + *failed_index = order[sql_failed_index - 1]; + } + return qs; +} diff --git a/src/backenddb/do_fountain_withdraw.sql b/src/backenddb/do_fountain_withdraw.sql @@ -0,0 +1,101 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- 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. +-- +-- 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 +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +DROP FUNCTION IF EXISTS merchant_do_fountain_withdraw; +CREATE FUNCTION merchant_do_fountain_withdraw( + IN in_fountain_serial INT8 + ,IN in_token_family_serials INT8[] + ,IN in_slot_starts INT8[] + ,IN in_num_requested INT8[] + ,OUT out_failed_index INT4 + ,OUT out_no_grant BOOL + ,OUT out_exceeded BOOL) +LANGUAGE plpgsql +AS $$ +-- Atomically checks and consumes per-period withdrawal quota for a +-- fountain. The parallel input arrays hold one entry per requested +-- (token family, key slot) pair; entries must be distinct and sorted +-- by (token_family_serial, slot_start) by the caller so that +-- concurrent requests acquire row locks in a deterministic order. +-- +-- All-or-nothing: if any entry has no matching grant or would exceed +-- its tokens_per_period_limit, no counter is modified and +-- out_failed_index reports the offending (1-based) entry. +DECLARE + my_limit INT8; + my_count INT8; + i INT4; +BEGIN + out_failed_index = 0; + out_no_grant = FALSE; + out_exceeded = FALSE; + + -- Pass 1: lock counters and check limits without modifying them. + FOR i IN 1..COALESCE(array_length (in_token_family_serials, 1), 0) + LOOP + SELECT tokens_per_period_limit + INTO my_limit + FROM merchant_fountain_grants + WHERE fountain_serial = in_fountain_serial + AND token_family_serial = in_token_family_serials[i]; + IF NOT FOUND + THEN + out_no_grant = TRUE; + out_failed_index = i; + RETURN; + END IF; + + -- Ensure the counter row exists (a zero-count row is semantically + -- inert, so leaving it behind on failure is harmless), then lock it. + INSERT INTO merchant_fountain_withdrawals + (fountain_serial + ,token_family_serial + ,slot_start + ,num_withdrawn + ) VALUES + (in_fountain_serial + ,in_token_family_serials[i] + ,in_slot_starts[i] + ,0) + ON CONFLICT DO NOTHING; + + SELECT num_withdrawn + INTO my_count + FROM merchant_fountain_withdrawals + WHERE fountain_serial = in_fountain_serial + AND token_family_serial = in_token_family_serials[i] + AND slot_start = in_slot_starts[i] + FOR UPDATE; + + IF my_count + in_num_requested[i] > my_limit + THEN + out_exceeded = TRUE; + out_failed_index = i; + RETURN; + END IF; + END LOOP; + + -- Pass 2: all entries fit, consume the quota. + FOR i IN 1..COALESCE(array_length (in_token_family_serials, 1), 0) + LOOP + UPDATE merchant_fountain_withdrawals + SET num_withdrawn = num_withdrawn + in_num_requested[i] + WHERE fountain_serial = in_fountain_serial + AND token_family_serial = in_token_family_serials[i] + AND slot_start = in_slot_starts[i]; + END LOOP; + +END $$; diff --git a/src/backenddb/do_insert_fountain.c b/src/backenddb/do_insert_fountain.c @@ -0,0 +1,98 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/do_insert_fountain.c + * @brief Implementation of the do_insert_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/do_insert_fountain.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_do_insert_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id, + const struct GNUNET_HashCode *h_fountain_secret, + const char *description, + struct GNUNET_TIME_Relative poll_freq, + unsigned int grants_len, + const struct TALER_MERCHANTDB_FountainGrant grants[static grants_len], + char **unknown_slug, + bool *conflict) +{ + const char *slugs[GNUNET_NZL (grants_len)]; + uint64_t limits[GNUNET_NZL (grants_len)]; + uint64_t stashes[GNUNET_NZL (grants_len)]; + uint64_t windows[GNUNET_NZL (grants_len)]; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (fountain_id), + GNUNET_PQ_query_param_auto_from_type (h_fountain_secret), + GNUNET_PQ_query_param_string (description), + GNUNET_PQ_query_param_relative_time (&poll_freq), + GNUNET_PQ_query_param_array_ptrs_string (grants_len, + slugs, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (grants_len, + limits, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (grants_len, + stashes, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (grants_len, + windows, + pg->conn), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_string ("out_unknown_slug", + unknown_slug), + NULL), + GNUNET_PQ_result_spec_bool ("out_conflict", + conflict), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; + + *unknown_slug = NULL; + *conflict = false; + for (unsigned int i = 0; i < grants_len; i++) + { + slugs[i] = grants[i].token_family_slug; + limits[i] = grants[i].tokens_per_period_limit; + stashes[i] = grants[i].tokens_per_period_stash; + windows[i] = grants[i].key_window_size; + } + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "SELECT" + " out_unknown_slug" + " ,out_conflict" + " FROM merchant_do_insert_fountain" + " ($1, $2, $3, $4, $5, $6, $7, $8);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); + GNUNET_PQ_cleanup_query_params_closures (params); + return qs; +} diff --git a/src/backenddb/do_insert_fountain.sql b/src/backenddb/do_insert_fountain.sql @@ -0,0 +1,92 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- 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. +-- +-- 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 +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +DROP FUNCTION IF EXISTS merchant_do_insert_fountain; +CREATE FUNCTION merchant_do_insert_fountain( + IN in_fountain_id TEXT + ,IN in_h_fountain_secret BYTEA + ,IN in_description TEXT + ,IN in_poll_freq INT8 + ,IN in_token_family_slugs TEXT[] + ,IN in_tokens_per_period_limits INT8[] + ,IN in_tokens_per_period_stashes INT8[] + ,IN in_key_window_sizes INT8[] + ,OUT out_unknown_slug TEXT + ,OUT out_conflict BOOL) +LANGUAGE plpgsql +AS $$ +DECLARE + my_tf_serials INT8[]; + my_tf_serial INT8; + my_fountain_serial INT8; + i INT4; +BEGIN + out_unknown_slug = NULL; + out_conflict = FALSE; + + -- Resolve all token family slugs before creating anything, so an + -- unknown slug leaves no trace. + my_tf_serials = ARRAY[]::INT8[]; + FOR i IN 1..COALESCE(array_length (in_token_family_slugs, 1), 0) + LOOP + SELECT token_family_serial + INTO my_tf_serial + FROM merchant_token_families + WHERE slug = in_token_family_slugs[i]; + IF NOT FOUND + THEN + out_unknown_slug = in_token_family_slugs[i]; + RETURN; + END IF; + my_tf_serials = array_append (my_tf_serials, my_tf_serial); + END LOOP; + + INSERT INTO merchant_fountains + (fountain_id + ,h_fountain_secret + ,description + ,poll_freq + ) VALUES + (in_fountain_id + ,in_h_fountain_secret + ,in_description + ,in_poll_freq) + ON CONFLICT DO NOTHING + RETURNING fountain_serial + INTO my_fountain_serial; + IF NOT FOUND + THEN + out_conflict = TRUE; + RETURN; + END IF; + + FOR i IN 1..COALESCE(array_length (my_tf_serials, 1), 0) + LOOP + INSERT INTO merchant_fountain_grants + (fountain_serial + ,token_family_serial + ,tokens_per_period_limit + ,tokens_per_period_stash + ,key_window_size + ) VALUES + (my_fountain_serial + ,my_tf_serials[i] + ,in_tokens_per_period_limits[i] + ,in_tokens_per_period_stashes[i] + ,in_key_window_sizes[i]); + END LOOP; + +END $$; diff --git a/src/backenddb/do_update_fountain.c b/src/backenddb/do_update_fountain.c @@ -0,0 +1,102 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/do_update_fountain.c + * @brief Implementation of the do_update_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/do_update_fountain.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_do_update_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id, + const char *description, + const struct GNUNET_TIME_Relative *poll_freq, + bool replace_grants, + unsigned int grants_len, + const struct TALER_MERCHANTDB_FountainGrant *grants, + bool *not_found, + char **unknown_slug) +{ + const char *slugs[GNUNET_NZL (grants_len)]; + uint64_t limits[GNUNET_NZL (grants_len)]; + uint64_t stashes[GNUNET_NZL (grants_len)]; + uint64_t windows[GNUNET_NZL (grants_len)]; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (fountain_id), + (NULL == description) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_string (description), + (NULL == poll_freq) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_relative_time (poll_freq), + GNUNET_PQ_query_param_bool (replace_grants), + GNUNET_PQ_query_param_array_ptrs_string (grants_len, + slugs, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (grants_len, + limits, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (grants_len, + stashes, + pg->conn), + GNUNET_PQ_query_param_array_uint64 (grants_len, + windows, + pg->conn), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("out_not_found", + not_found), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_string ("out_unknown_slug", + unknown_slug), + NULL), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; + + *not_found = false; + *unknown_slug = NULL; + for (unsigned int i = 0; i < grants_len; i++) + { + slugs[i] = grants[i].token_family_slug; + limits[i] = grants[i].tokens_per_period_limit; + stashes[i] = grants[i].tokens_per_period_stash; + windows[i] = grants[i].key_window_size; + } + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "SELECT" + " out_not_found" + " ,out_unknown_slug" + " FROM merchant_do_update_fountain" + " ($1, $2, $3, $4, $5, $6, $7, $8);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); + GNUNET_PQ_cleanup_query_params_closures (params); + return qs; +} diff --git a/src/backenddb/do_update_fountain.sql b/src/backenddb/do_update_fountain.sql @@ -0,0 +1,100 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- 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. +-- +-- 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 +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +DROP FUNCTION IF EXISTS merchant_do_update_fountain; +CREATE FUNCTION merchant_do_update_fountain( + IN in_fountain_id TEXT + ,IN in_description TEXT -- can be NULL: keep previous value + ,IN in_poll_freq INT8 -- can be NULL: keep previous value + ,IN in_replace_grants BOOL + ,IN in_token_family_slugs TEXT[] + ,IN in_tokens_per_period_limits INT8[] + ,IN in_tokens_per_period_stashes INT8[] + ,IN in_key_window_sizes INT8[] + ,OUT out_not_found BOOL + ,OUT out_unknown_slug TEXT) +LANGUAGE plpgsql +AS $$ +DECLARE + my_tf_serials INT8[]; + my_tf_serial INT8; + my_fountain_serial INT8; + i INT4; +BEGIN + out_not_found = FALSE; + out_unknown_slug = NULL; + + SELECT fountain_serial + INTO my_fountain_serial + FROM merchant_fountains + WHERE fountain_id = in_fountain_id; + IF NOT FOUND + THEN + out_not_found = TRUE; + RETURN; + END IF; + + -- Resolve all token family slugs before modifying anything, so an + -- unknown slug leaves the fountain unchanged. + my_tf_serials = ARRAY[]::INT8[]; + IF in_replace_grants + THEN + FOR i IN 1..COALESCE(array_length (in_token_family_slugs, 1), 0) + LOOP + SELECT token_family_serial + INTO my_tf_serial + FROM merchant_token_families + WHERE slug = in_token_family_slugs[i]; + IF NOT FOUND + THEN + out_unknown_slug = in_token_family_slugs[i]; + RETURN; + END IF; + my_tf_serials = array_append (my_tf_serials, my_tf_serial); + END LOOP; + END IF; + + UPDATE merchant_fountains + SET description = COALESCE(in_description, description) + ,poll_freq = COALESCE(in_poll_freq, poll_freq) + WHERE fountain_serial = my_fountain_serial; + + IF in_replace_grants + THEN + -- Replace the grant set. Withdrawal counters in + -- merchant_fountain_withdrawals are deliberately left untouched: + -- consumed quota must survive a grant replacement. + DELETE FROM merchant_fountain_grants + WHERE fountain_serial = my_fountain_serial; + + FOR i IN 1..COALESCE(array_length (my_tf_serials, 1), 0) + LOOP + INSERT INTO merchant_fountain_grants + (fountain_serial + ,token_family_serial + ,tokens_per_period_limit + ,tokens_per_period_stash + ,key_window_size + ) VALUES + (my_fountain_serial + ,my_tf_serials[i] + ,in_tokens_per_period_limits[i] + ,in_tokens_per_period_stashes[i] + ,in_key_window_sizes[i]); + END LOOP; + END IF; + +END $$; diff --git a/src/backenddb/gc.sql b/src/backenddb/gc.sql @@ -34,6 +34,24 @@ BEGIN EXECUTE format('CALL %I.merchant_statistic_counter_gc()', s); EXECUTE format('DELETE FROM %I.merchant_unclaim_signatures' ' WHERE expiration_time < $1', s) USING in_now; + -- Compare elapsed time instead of adding the duration to the start: + -- indefinite durations use INT8_MAX and must never overflow here. + EXECUTE format('DELETE FROM %I.merchant_fountain_withdrawals fw' + ' USING %I.merchant_token_families tf' + ' WHERE tf.token_family_serial = fw.token_family_serial' + ' AND tf.duration < $1 - fw.slot_start', s, s) + USING in_now; + -- Keep the complete response until the last issue key of that + -- withdrawal expires. Collecting individual signatures would leave + -- an incomplete response for requests spanning multiple key periods. + EXECUTE format('DELETE FROM %I.merchant_fountain_withdraw_sigs fws' + ' USING (SELECT saved.fountain_serial, saved.h_request' + ' FROM %I.merchant_fountain_withdraw_sigs saved' + ' GROUP BY saved.fountain_serial, saved.h_request' + ' HAVING max(saved.signature_validity_end) < $1) expired' + ' WHERE fws.fountain_serial = expired.fountain_serial' + ' AND fws.h_request = expired.h_request', s, s) + USING in_now; EXCEPTION WHEN undefined_table THEN NULL; diff --git a/src/backenddb/get_fountain.c b/src/backenddb/get_fountain.c @@ -0,0 +1,62 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/get_fountain.c + * @brief Implementation of the get_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/get_fountain.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_get_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id, + struct TALER_MERCHANTDB_FountainDetails *fd) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (fountain_id), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("fountain_serial", + &fd->fountain_serial), + GNUNET_PQ_result_spec_string ("description", + &fd->description), + GNUNET_PQ_result_spec_relative_time ("poll_freq", + &fd->poll_freq), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "SELECT" + " fountain_serial" + ",description" + ",poll_freq" + " FROM merchant_fountains" + " WHERE fountain_id=$1"); + return GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); +} diff --git a/src/backenddb/get_fountain_by_secret.c b/src/backenddb/get_fountain_by_secret.c @@ -0,0 +1,60 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/get_fountain_by_secret.c + * @brief Implementation of the get_fountain_by_secret function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/get_fountain_by_secret.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_get_fountain_by_secret ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const struct GNUNET_HashCode *h_fountain_secret, + uint64_t *fountain_serial, + struct GNUNET_TIME_Relative *poll_freq) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (h_fountain_secret), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("fountain_serial", + fountain_serial), + GNUNET_PQ_result_spec_relative_time ("poll_freq", + poll_freq), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "SELECT" + " fountain_serial" + ",poll_freq" + " FROM merchant_fountains" + " WHERE h_fountain_secret=$1"); + return GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); +} diff --git a/src/backenddb/get_fountain_withdraw.c b/src/backenddb/get_fountain_withdraw.c @@ -0,0 +1,175 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ +/** + * @file src/backenddb/get_fountain_withdraw.c + * @brief look up the signatures of a completed fountain withdrawal + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/get_fountain_withdraw.h" +#include "helper.h" + + +/** + * Closure for #restore_sig_cb(). + */ +struct RestoreContext +{ + /** + * Callback to hand each signature to. + */ + TALER_MERCHANTDB_FountainWithdrawSigCallback cb; + + /** + * Closure for @e cb. + */ + void *cb_cls; + + /** + * Set to a hard error if a row could not be extracted. + */ + enum GNUNET_DB_QueryStatus qs; +}; + + +/** + * Extract the stored signatures and pass them on in response order. + * + * @param cls a `struct RestoreContext *` + * @param result the postgres result + * @param num_results number of rows in @a result + */ +static void +restore_sig_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct RestoreContext *rc = cls; + + for (unsigned int i = 0; i < num_results; i++) + { + uint32_t grant_index; + char *slug; + struct TALER_TokenIssuePublicKeyHashP h_issue; + struct GNUNET_CRYPTO_BlindedSignature *sig; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint32 ("grant_index", + &grant_index), + GNUNET_PQ_result_spec_string ("token_family_slug", + &slug), + GNUNET_PQ_result_spec_auto_from_type ("h_issue", + &h_issue), + GNUNET_PQ_result_spec_blinded_sig ("token_blinded_signature", + &sig), + GNUNET_PQ_result_spec_end + }; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + rc->qs = GNUNET_DB_STATUS_HARD_ERROR; + return; + } + rc->cb (rc->cb_cls, + grant_index, + slug, + &h_issue, + sig); + GNUNET_PQ_cleanup_result (rs); + } +} + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_get_fountain_withdraw ( + struct TALER_MERCHANTDB_PostgresContext *pg, + uint64_t fountain_serial, + const struct GNUNET_HashCode *h_request, + bool *not_found, + TALER_MERCHANTDB_FountainWithdrawSigCallback cb, + void *cb_cls) +{ + struct RestoreContext rc = { + .cb = cb, + .cb_cls = cb_cls, + .qs = GNUNET_DB_STATUS_SUCCESS_NO_RESULTS + }; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (NULL != pg->transaction_name); + *not_found = false; + { + uint64_t serial; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_uint64 (&fountain_serial), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("fountain_serial", + &serial), + GNUNET_PQ_result_spec_end + }; + + TMH_PQ_prepare_anon (pg, + "SELECT fountain_serial" + " FROM merchant_fountains" + " WHERE fountain_serial = $1" + " FOR UPDATE"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); + if (qs <= 0) + { + *not_found = (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs); + return qs; + } + } + { + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_uint64 (&fountain_serial), + GNUNET_PQ_query_param_auto_from_type (h_request), + GNUNET_PQ_query_param_end + }; + + /* A fresh statement snapshot: this runs after waiting for the lock, + so it sees a withdrawal that a competing request just committed. */ + TMH_PQ_prepare_anon (pg, + "SELECT" + " fws.grant_index" + " ,fws.token_blinded_signature" + " ,fws.h_issue" + " ,fws.token_family_slug" + " FROM merchant_fountain_withdraw_sigs AS fws" + " WHERE fws.fountain_serial = $1" + " AND fws.h_request = $2" + " ORDER BY fws.grant_index ASC" + " ,fws.token_index ASC"); + qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn, + "", + params, + &restore_sig_cb, + &rc); + /* propagate an extraction failure recorded by the row callback */ + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != rc.qs) + return rc.qs; + return qs; + } +} diff --git a/src/backenddb/get_token_family_key.c b/src/backenddb/get_token_family_key.c @@ -26,10 +26,47 @@ #include <taler/taler_dbevents.h> #include <taler/taler_pq_lib.h> #include "merchant-database/get_token_family_key.h" +#include "merchant-database/start.h" #include "helper.h" enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_lock_token_family ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *token_family_slug, + bool *started_transaction) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (token_family_slug), + GNUNET_PQ_query_param_end + }; + + *started_transaction = false; + GNUNET_assert (NULL != pg->current_merchant_id); + if (NULL == pg->transaction_name) + { + if (GNUNET_OK != + TALER_MERCHANTDB_start_read_committed (pg, + "ensure token family key")) + return GNUNET_DB_STATUS_HARD_ERROR; + *started_transaction = true; + } + /* Touch the parent even when no keys exist. A plain SELECT FOR UPDATE + would allow a caller with an older SERIALIZABLE snapshot to miss keys + inserted by the previous lock holder. Updating the row forces that + caller to fail with a serialization error instead of minting a duplicate. + Under READ COMMITTED the subsequent lookup sees the committed key. */ + TMH_PQ_prepare_anon (pg, + "UPDATE merchant_token_families" + " SET issued=issued" + " WHERE slug=$1"); + return GNUNET_PQ_eval_prepared_non_select (pg->conn, + "", + params); +} + + +enum GNUNET_DB_QueryStatus TALER_MERCHANTDB_get_token_family_key ( struct TALER_MERCHANTDB_PostgresContext *pg, const char *instance_id, diff --git a/src/backenddb/insert_fountain_withdraw_sig.c b/src/backenddb/insert_fountain_withdraw_sig.c @@ -0,0 +1,68 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ +/** + * @file src/backenddb/insert_fountain_withdraw_sig.c + * @brief record one blind signature of a completed fountain withdrawal + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/insert_fountain_withdraw_sig.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_insert_fountain_withdraw_sig ( + struct TALER_MERCHANTDB_PostgresContext *pg, + uint64_t fountain_serial, + const struct GNUNET_HashCode *h_request, + uint32_t grant_index, + uint32_t token_index, + const struct TALER_TokenIssuePublicKeyHashP *h_issue_pub, + const struct TALER_BlindedTokenIssueSignature *blind_sig) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_uint64 (&fountain_serial), + GNUNET_PQ_query_param_auto_from_type (h_request), + GNUNET_PQ_query_param_uint32 (&grant_index), + GNUNET_PQ_query_param_uint32 (&token_index), + GNUNET_PQ_query_param_auto_from_type (h_issue_pub), + GNUNET_PQ_query_param_blinded_sig (blind_sig->signature), + GNUNET_PQ_query_param_end + }; + + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (NULL != pg->transaction_name); + TMH_PQ_prepare_anon (pg, + "INSERT INTO merchant_fountain_withdraw_sigs" + " (fountain_serial" + " ,h_request" + " ,grant_index" + " ,token_index" + " ,token_family_slug" + " ,h_issue" + " ,signature_validity_end" + " ,token_blinded_signature" + ")" + " SELECT $1, $2, $3, $4," + " tf.slug, k.h_pub, k.signature_validity_end, $6" + " FROM merchant_token_family_keys k" + " JOIN merchant_token_families tf" + " USING (token_family_serial)" + " WHERE k.h_pub = $5"); + return GNUNET_PQ_eval_prepared_non_select (pg->conn, + "", + params); +} diff --git a/src/backenddb/insert_issued_token.c b/src/backenddb/insert_issued_token.c @@ -34,7 +34,9 @@ TALER_MERCHANTDB_insert_issued_token ( { struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_auto_from_type (h_issue_pub), - GNUNET_PQ_query_param_auto_from_type (h_contract_terms), + (NULL == h_contract_terms) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_auto_from_type (h_contract_terms), GNUNET_PQ_query_param_blinded_sig (blind_sig->signature), GNUNET_PQ_query_param_end }; diff --git a/src/backenddb/iterate_fountain_grants.c b/src/backenddb/iterate_fountain_grants.c @@ -0,0 +1,147 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/iterate_fountain_grants.c + * @brief Implementation of the iterate_fountain_grants function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/iterate_fountain_grants.h" +#include "helper.h" + + +/** + * Context used for TALER_MERCHANTDB_iterate_fountain_grants(). + */ +struct LookupFountainGrantsContext +{ + /** + * Function to call with the results. + */ + TALER_MERCHANTDB_FountainGrantsCallback cb; + + /** + * Closure for @a cb. + */ + void *cb_cls; + + /** + * Did database result extraction fail? + */ + bool extract_failed; +}; + + +/** + * Function to be called with the results of a SELECT statement + * that has returned @a num_results results about fountain grants. + * + * @param[in,out] cls of type `struct LookupFountainGrantsContext *` + * @param result the postgres result + * @param num_results the number of results in @a result + */ +static void +lookup_fountain_grants_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct LookupFountainGrantsContext *lgc = cls; + + for (unsigned int i = 0; i < num_results; i++) + { + char *token_family_slug; + uint64_t token_family_serial; + uint64_t tokens_per_period_limit; + uint64_t tokens_per_period_stash; + uint64_t key_window_size; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_string ("slug", + &token_family_slug), + GNUNET_PQ_result_spec_uint64 ("token_family_serial", + &token_family_serial), + GNUNET_PQ_result_spec_uint64 ("tokens_per_period_limit", + &tokens_per_period_limit), + GNUNET_PQ_result_spec_uint64 ("tokens_per_period_stash", + &tokens_per_period_stash), + GNUNET_PQ_result_spec_uint64 ("key_window_size", + &key_window_size), + GNUNET_PQ_result_spec_end + }; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + lgc->extract_failed = true; + return; + } + lgc->cb (lgc->cb_cls, + token_family_slug, + token_family_serial, + tokens_per_period_limit, + tokens_per_period_stash, + (uint32_t) key_window_size); + GNUNET_PQ_cleanup_result (rs); + } +} + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_iterate_fountain_grants ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + uint64_t fountain_serial, + TALER_MERCHANTDB_FountainGrantsCallback cb, + void *cb_cls) +{ + struct LookupFountainGrantsContext lgc = { + .cb = cb, + .cb_cls = cb_cls, + .extract_failed = false, + }; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_uint64 (&fountain_serial), + GNUNET_PQ_query_param_end + }; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "SELECT" + " tf.slug" + ",fg.token_family_serial" + ",fg.tokens_per_period_limit" + ",fg.tokens_per_period_stash" + ",fg.key_window_size" + " FROM merchant_fountain_grants fg" + " JOIN merchant_token_families tf" + " USING (token_family_serial)" + " WHERE fg.fountain_serial=$1" + " ORDER BY tf.slug ASC"); + qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn, + "", + params, + &lookup_fountain_grants_cb, + &lgc); + if (lgc.extract_failed) + return GNUNET_DB_STATUS_HARD_ERROR; + return qs; +} diff --git a/src/backenddb/iterate_fountains.c b/src/backenddb/iterate_fountains.c @@ -0,0 +1,126 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/iterate_fountains.c + * @brief Implementation of the iterate_fountains function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/iterate_fountains.h" +#include "helper.h" + + +/** + * Context used for TALER_MERCHANTDB_iterate_fountains(). + */ +struct LookupFountainsContext +{ + /** + * Function to call with the results. + */ + TALER_MERCHANTDB_FountainsCallback cb; + + /** + * Closure for @a cb. + */ + void *cb_cls; + + /** + * Did database result extraction fail? + */ + bool extract_failed; +}; + + +/** + * Function to be called with the results of a SELECT statement + * that has returned @a num_results results about fountains. + * + * @param[in,out] cls of type `struct LookupFountainsContext *` + * @param result the postgres result + * @param num_results the number of results in @a result + */ +static void +lookup_fountains_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct LookupFountainsContext *lfc = cls; + + for (unsigned int i = 0; i < num_results; i++) + { + char *fountain_id; + char *description; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_string ("fountain_id", + &fountain_id), + GNUNET_PQ_result_spec_string ("description", + &description), + GNUNET_PQ_result_spec_end + }; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + lfc->extract_failed = true; + return; + } + lfc->cb (lfc->cb_cls, + fountain_id, + description); + GNUNET_PQ_cleanup_result (rs); + } +} + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_iterate_fountains ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + TALER_MERCHANTDB_FountainsCallback cb, + void *cb_cls) +{ + struct LookupFountainsContext lfc = { + .cb = cb, + .cb_cls = cb_cls, + .extract_failed = false, + }; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_end + }; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_assert (NULL != pg->current_merchant_id); + GNUNET_assert (0 == strcmp (instance_id, + pg->current_merchant_id)); + TMH_PQ_prepare_anon (pg, + "SELECT" + " fountain_id" + ",description" + " FROM merchant_fountains"); + qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn, + "", + params, + &lookup_fountains_cb, + &lfc); + if (lfc.extract_failed) + return GNUNET_DB_STATUS_HARD_ERROR; + return qs; +} diff --git a/src/backenddb/meson.build b/src/backenddb/meson.build @@ -20,6 +20,16 @@ libtalermerchantdb = library( 'insert_kyc_failure.c', 'insert_kyc_status.c', 'do_insert_account.c', + 'do_insert_fountain.c', + 'do_update_fountain.c', + 'do_fountain_withdraw.c', + 'delete_fountain.c', + 'get_fountain.c', + 'get_fountain_withdraw.c', + 'insert_fountain_withdraw_sig.c', + 'get_fountain_by_secret.c', + 'iterate_fountains.c', + 'iterate_fountain_grants.c', 'get_exists_transfer.c', 'delete_category.c', 'delete_contract_terms.c', diff --git a/src/backenddb/sql-schema/merchant-0048.sql b/src/backenddb/sql-schema/merchant-0048.sql @@ -12,9 +12,10 @@ -- -- You should have received a copy of the GNU General Public License along with -- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- -- @file merchant-0048.sql --- @brief Add challenge-signature POS confirmations (DD 97) +-- @brief Add DD97 challenge confirmations and DD98 token fountains. -- @author Bohdan Potuzhnyi BEGIN; @@ -50,17 +51,145 @@ BEGIN COMMENT ON COLUMN merchant_contract_terms.pos_challenge IS 'Challenge to sign when pos_algorithm is a challenge-signature algorithm, NULL otherwise'; + + CREATE TABLE merchant_fountains ( + fountain_serial INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + fountain_id TEXT NOT NULL UNIQUE, + h_fountain_secret BYTEA NOT NULL UNIQUE, + description TEXT NOT NULL, + poll_freq INT8 NOT NULL, + CONSTRAINT merchant_fountains_h_fountain_secret_check + CHECK ((LENGTH(h_fountain_secret) = 64)) + ); + COMMENT ON TABLE merchant_fountains IS + 'Token fountains: bearer credentials that entitle wallets to' + ' withdraw blind-signed promotional tokens.'; + COMMENT ON COLUMN merchant_fountains.fountain_id IS + 'Public identifier of the fountain, included in the wallet' + ' onboarding URI.'; + COMMENT ON COLUMN merchant_fountains.h_fountain_secret IS + 'Hash of the bearer credential; the secret itself is never stored.'; + COMMENT ON COLUMN merchant_fountains.description IS + 'Human-readable description, preferably an opaque campaign or' + ' recipient reference of the institution.'; + COMMENT ON COLUMN merchant_fountains.poll_freq IS + 'How often wallets should re-poll the fountain information.'; + + CREATE TABLE merchant_fountain_grants ( + fountain_grant_serial INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + fountain_serial INT8 NOT NULL, + token_family_serial INT8 NOT NULL, + tokens_per_period_limit INT8 NOT NULL, + tokens_per_period_stash INT8 NOT NULL, + key_window_size INT8 NOT NULL, + CONSTRAINT merchant_fountain_grants_limit_check + CHECK ((tokens_per_period_limit >= 0)), + CONSTRAINT merchant_fountain_grants_stash_check + CHECK ((tokens_per_period_stash BETWEEN 0 AND tokens_per_period_limit)), + CONSTRAINT merchant_fountain_grants_window_check + CHECK ((key_window_size >= 0)), + CONSTRAINT merchant_fountain_grants_unique + UNIQUE (fountain_serial, token_family_serial), + CONSTRAINT merchant_fountain_grants_fountain_serial_fkey + FOREIGN KEY (fountain_serial) + REFERENCES merchant_fountains(fountain_serial) ON DELETE CASCADE, + CONSTRAINT merchant_fountain_grants_token_family_serial_fkey + FOREIGN KEY (token_family_serial) + REFERENCES merchant_token_families(token_family_serial) ON DELETE CASCADE + ); + COMMENT ON TABLE merchant_fountain_grants IS + 'Withdrawal rights of a fountain: which token families may be' + ' withdrawn and at what rate.'; + COMMENT ON COLUMN merchant_fountain_grants.tokens_per_period_limit IS + 'Maximum number of tokens blind-signed per issue-key validity' + ' period for this token family.'; + COMMENT ON COLUMN merchant_fountain_grants.tokens_per_period_stash IS + 'Number of tokens the wallet should aim to hold per period; a' + ' client-side stocking target, at most tokens_per_period_limit.'; + COMMENT ON COLUMN merchant_fountain_grants.key_window_size IS + 'Number of issue-key slots ahead of the current one for which the' + ' wallet may withdraw tokens.'; + + CREATE TABLE merchant_fountain_withdrawals ( + fountain_withdrawal_serial INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + fountain_serial INT8 NOT NULL, + token_family_serial INT8 NOT NULL, + slot_start INT8 NOT NULL, + num_withdrawn INT8 DEFAULT 0 NOT NULL, + CONSTRAINT merchant_fountain_withdrawals_unique + UNIQUE (fountain_serial, token_family_serial, slot_start), + CONSTRAINT merchant_fountain_withdrawals_fountain_serial_fkey + FOREIGN KEY (fountain_serial) + REFERENCES merchant_fountains(fountain_serial) ON DELETE CASCADE, + CONSTRAINT merchant_fountain_withdrawals_token_family_serial_fkey + FOREIGN KEY (token_family_serial) + REFERENCES merchant_token_families(token_family_serial) ON DELETE CASCADE + ); + COMMENT ON TABLE merchant_fountain_withdrawals IS + 'Per-period withdrawal counters enforcing the limits of the' + ' fountain grants. Deliberately not referencing' + ' merchant_fountain_grants: replacing the grants of a fountain' + ' must not reset the quota already consumed.'; + COMMENT ON COLUMN merchant_fountain_withdrawals.slot_start IS + 'signature_validity_start of the issue-key validity period this' + ' counter accounts for.'; + COMMENT ON COLUMN merchant_fountain_withdrawals.num_withdrawn IS + 'Number of tokens already blind-signed for this fountain, token' + ' family and validity period.'; + + CREATE TABLE merchant_fountain_withdraw_sigs ( + fountain_serial INT8 NOT NULL, + h_request BYTEA NOT NULL, + grant_index INT4 NOT NULL, + token_index INT4 NOT NULL, + token_family_slug TEXT NOT NULL, + h_issue BYTEA NOT NULL CHECK (LENGTH(h_issue) = 64), + signature_validity_end INT8 NOT NULL, + token_blinded_signature BYTEA NOT NULL, + CONSTRAINT merchant_fountain_withdraw_sigs_h_request_check + CHECK ((LENGTH(h_request) = 64)), + CONSTRAINT merchant_fountain_withdraw_sigs_pkey + PRIMARY KEY (fountain_serial, h_request, grant_index, token_index), + CONSTRAINT merchant_fountain_withdraw_sigs_fountain_serial_fkey + FOREIGN KEY (fountain_serial) + REFERENCES merchant_fountains(fountain_serial) ON DELETE CASCADE + ); + COMMENT ON TABLE merchant_fountain_withdraw_sigs IS + 'Blind signatures handed out by a completed withdrawal, committed' + ' together with the quota consumption and the issued tokens. A' + ' repeated request is answered by rebuilding its response from' + ' these rows instead of consuming quota a second time.'; + COMMENT ON COLUMN merchant_fountain_withdraw_sigs.h_request IS + 'Hash of the canonical JSON of the grants array of the request.' + ' Neither the bearer credential nor unblinded token data is' + ' stored.'; + COMMENT ON COLUMN merchant_fountain_withdraw_sigs.grant_index IS + 'Offset of the grant this signature belongs to in the request,' + ' and hence in the response.'; + COMMENT ON COLUMN merchant_fountain_withdraw_sigs.token_index IS + 'Offset of the signature within the token_sigs array of that' + ' grant, matching the order of the submitted envelopes.'; + COMMENT ON COLUMN merchant_fountain_withdraw_sigs.token_family_slug IS + 'Token family slug returned in the original withdrawal response.'; + COMMENT ON COLUMN merchant_fountain_withdraw_sigs.h_issue IS + 'Issue-key hash returned in the original withdrawal response.'; + COMMENT ON COLUMN merchant_fountain_withdraw_sigs.signature_validity_end IS + 'Issue-key expiry at withdrawal time. GC retains the complete request' + ' until the latest expiry, independently of family or key deletion.'; + + ALTER TABLE merchant_issued_tokens + ALTER COLUMN h_contract_terms DROP NOT NULL; + COMMENT ON COLUMN merchant_issued_tokens.h_contract_terms IS + 'This is no foreign key by design. NULL for tokens issued via a' + ' fountain instead of as an output of an order.'; + SET LOCAL search_path TO merchant; END $OUTER$; INSERT INTO merchant.instance_fixups - (migration_name - ,version) - VALUES - ('merchant_0048_init' - ,48); --- Apply new fix-up to existing instances + (migration_name, version) + VALUES ('merchant_0048_init', 48); CALL merchant.fixup_instance_schema (48::INT8); COMMIT; diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build @@ -65,6 +65,9 @@ sql_instance_procedures = [ '../do_insert_account.sql', '../update_to_account_inactive.sql', '../do_expire_locks.sql', + '../do_insert_fountain.sql', + '../do_update_fountain.sql', + '../do_fountain_withdraw.sql', '../pg_merchant_send_account_notification.sql', '../pg_merchant_account_trigger.sql', '../pg_merchant_send_kyc_notification.sql', diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -78,6 +78,7 @@ #include "merchant-database/insert_token_family.h" #include "merchant-database/insert_token_family_key.h" #include "merchant-database/get_token_family.h" +#include "merchant-database/get_token_family_key.h" #include "merchant-database/delete_token_family.h" #include "merchant-database/get_account_serial.h" #include "merchant-database/get_contract_terms.h" @@ -2950,6 +2951,135 @@ test_insert_used_token (const struct TestTokens_Closure *cls, /** + * Serialize a previously empty family across independent connections, then + * verify that a waiting creator sees the committed key. Also reject an old + * SERIALIZABLE snapshot rather than allowing it to mint from stale data. + */ +static int +test_token_family_key_lock (struct TestTokens_Closure *cls) +{ + struct TALER_MERCHANTDB_TokenFamilyKeyDetails kd; + bool started; + int to_child[2]; + int from_child[2]; + pid_t child; + int status; + char signal; + + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_lock_token_family (pg, + cls->family.slug, + &started)); + GNUNET_assert (started); + /* Re-entering must retain the caller's transaction and its lock. */ + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_lock_token_family (pg, + cls->family.slug, + &started)); + GNUNET_assert (! started); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_token_family_key ( + pg, cls->instance.instance.id, cls->family.slug, + cls->family.valid_after, cls->family.valid_before, &kd)); + GNUNET_assert (NULL == kd.pub.public_key); + TALER_MERCHANTDB_token_family_details_free (&kd.token_family); + GNUNET_assert (0 == pipe (to_child)); + GNUNET_assert (0 == pipe (from_child)); + child = fork (); + GNUNET_assert (-1 != child); + if (0 == child) + { + struct TALER_MERCHANTDB_PostgresContext *other; + struct GNUNET_PQ_ExecuteStatement timeout[] = { + GNUNET_PQ_make_execute ("SET lock_timeout='100ms'"), + GNUNET_PQ_EXECUTE_STATEMENT_END + }; + + close (to_child[1]); + close (from_child[0]); + alarm (15); + other = TALER_MERCHANTDB_connect (test_cfg); + GNUNET_assert (NULL != other); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_set_instance (other, + cls->instance.instance.id)); + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_exec_statements (other->conn, + timeout)); + /* No key exists yet, but the second worker must still block. */ + GNUNET_assert (0 > TALER_MERCHANTDB_lock_token_family (other, + cls->family.slug, + &started)); + GNUNET_assert (started); + TALER_MERCHANTDB_rollback (other); + GNUNET_assert (1 == write (from_child[1], "B", 1)); + GNUNET_assert (1 == read (to_child[0], &signal, 1)); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_lock_token_family (other, + cls->family.slug, + &started)); + GNUNET_assert (started); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_token_family_key ( + other, cls->instance.instance.id, cls->family.slug, + cls->family.valid_after, cls->family.valid_before, &kd)); + GNUNET_assert (NULL != kd.pub.public_key); + GNUNET_assert (0 == GNUNET_memcmp (&cls->h_pub.hash, + &kd.pub.public_key->pub_key_hash)); + TALER_MERCHANTDB_token_family_details_free (&kd.token_family); + GNUNET_CRYPTO_blind_sign_pub_decref (kd.pub.public_key); + GNUNET_CRYPTO_blind_sign_priv_decref (kd.priv.private_key); + GNUNET_assert (0 <= TALER_MERCHANTDB_commit (other)); + /* Establish an old snapshot before the parent's next lock acquisition. */ + GNUNET_assert (GNUNET_OK == TALER_MERCHANTDB_start (other, + "old key snapshot")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_token_family_key ( + other, cls->instance.instance.id, cls->family.slug, + cls->family.valid_after, cls->family.valid_before, &kd)); + TALER_MERCHANTDB_token_family_details_free (&kd.token_family); + GNUNET_CRYPTO_blind_sign_pub_decref (kd.pub.public_key); + GNUNET_CRYPTO_blind_sign_priv_decref (kd.priv.private_key); + GNUNET_assert (1 == write (from_child[1], "S", 1)); + GNUNET_assert (1 == read (to_child[0], &signal, 1)); + GNUNET_assert (GNUNET_DB_STATUS_SOFT_ERROR == + TALER_MERCHANTDB_lock_token_family (other, + cls->family.slug, + &started)); + GNUNET_assert (! started); + TALER_MERCHANTDB_rollback (other); + TALER_MERCHANTDB_disconnect (other); + _exit (0); + } + close (to_child[0]); + close (from_child[1]); + GNUNET_assert (1 == read (from_child[0], &signal, 1)); + GNUNET_assert ('B' == signal); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_insert_token_family_key ( + pg, cls->instance.instance.id, cls->family.slug, + &cls->pub, &cls->priv, cls->family.valid_before, + cls->family.valid_after, cls->family.valid_before)); + GNUNET_assert (0 <= TALER_MERCHANTDB_commit (pg)); + GNUNET_assert (1 == write (to_child[1], "C", 1)); + GNUNET_assert (1 == read (from_child[0], &signal, 1)); + GNUNET_assert ('S' == signal); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_lock_token_family (pg, + cls->family.slug, + &started)); + GNUNET_assert (started); + GNUNET_assert (0 <= TALER_MERCHANTDB_commit (pg)); + GNUNET_assert (1 == write (to_child[1], "C", 1)); + close (to_child[1]); + close (from_child[0]); + GNUNET_assert (child == waitpid (child, &status, 0)); + GNUNET_assert (WIFEXITED (status) && (0 == WEXITSTATUS (status))); + return 0; +} + + +/** * Sets up the data structures used in the token tests. * * @param cls the closure to fill with test data. @@ -3043,17 +3173,7 @@ run_test_tokens (struct TestTokens_Closure *cls) "Insert token family failed\n"); TEST_SET_INSTANCE (cls->instance.instance.id, GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); - TEST_COND_RET_ON_FAIL ( - GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == - TALER_MERCHANTDB_insert_token_family_key (pg, - cls->instance.instance.id, - cls->family.slug, - &cls->pub, - &cls->priv, - cls->family.valid_before, - cls->family.valid_after, - cls->family.valid_before), - "Insert token family key failed\n"); + TEST_RET_ON_FAIL (test_token_family_key_lock (cls)); TEST_RET_ON_FAIL (test_token_family_counters (cls, 0, 0)); @@ -10683,6 +10803,218 @@ test_pending_webhooks (void) } +/** + * GC must remove expired finite fountain counters even when another + * counter has an indefinite duration. An overflow used to roll back + * the entire per-instance GC block, preserving the expired counter too. + * Replayable withdrawal signatures must be retained as a complete group + * until the last issue key of that withdrawal expires. + * + * @param instance instance for the test + * @return 0 on success, 1 otherwise + */ +static int +test_fountain_gc (const struct InstanceData *instance) +{ + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_RET_ON_FAIL (exec_sql ( + "INSERT INTO merchant_token_families" + " (slug, name, description_i18n, valid_after," + " valid_before, duration, kind)" + " VALUES ('gc-finite', 'GC finite', '{}', 0," + " 9223372036854775807, 60000000, 'discount')," + " ('gc-forever', 'GC forever', '{}', 0," + " 9223372036854775807, 9223372036854775807," + " 'discount');" + "INSERT INTO merchant_fountains" + " (fountain_id, h_fountain_secret, description," + " poll_freq)" + " VALUES ('gc-fountain'," + " decode(repeat('01',64),'hex')," + " 'GC test', 60000000);" + "INSERT INTO merchant_fountain_withdrawals" + " (fountain_serial, token_family_serial, slot_start," + " num_withdrawn)" + " SELECT f.fountain_serial, tf.token_family_serial," + " 1000000, 1" + " FROM merchant_fountains f" + " CROSS JOIN merchant_token_families tf" + " WHERE f.fountain_id='gc-fountain'" + " AND tf.slug IN ('gc-finite','gc-forever');" + /* One issue key long expired, one still valid. */ + "INSERT INTO merchant_token_family_keys" + " (token_family_serial, pub, h_pub, priv, cipher," + " signature_validity_start, signature_validity_end)" + " SELECT tf.token_family_serial, '\\x00'::BYTEA," + " decode(repeat('04',64),'hex')," + " '\\x00'::BYTEA, 'rsa', 0, 1000000" + " FROM merchant_token_families tf" + " WHERE tf.slug='gc-finite';" + "INSERT INTO merchant_token_family_keys" + " (token_family_serial, pub, h_pub, priv, cipher," + " signature_validity_start, signature_validity_end)" + " SELECT tf.token_family_serial, '\\x00'::BYTEA," + " decode(repeat('05',64),'hex')," + " '\\x00'::BYTEA, 'rsa', 0," + " 9223372036854775807" + " FROM merchant_token_families tf" + " WHERE tf.slug='gc-forever';" + "INSERT INTO merchant_fountain_withdraw_sigs" + " (fountain_serial, h_request, grant_index," + " token_index, token_family_slug, h_issue," + " signature_validity_end," + " token_blinded_signature)" + " SELECT f.fountain_serial," + " decode(repeat('02',64),'hex'), 0, 0," + " 'gc-finite', k.h_pub, k.signature_validity_end," + " '\\x00'::BYTEA" + " FROM merchant_fountains f" + " CROSS JOIN merchant_token_family_keys k" + " WHERE f.fountain_id='gc-fountain'" + " AND k.h_pub=decode(repeat('04',64),'hex');" + "INSERT INTO merchant_fountain_withdraw_sigs" + " (fountain_serial, h_request, grant_index," + " token_index, token_family_slug, h_issue," + " signature_validity_end," + " token_blinded_signature)" + " SELECT f.fountain_serial," + " decode(repeat('03',64),'hex'), 0, 0," + " 'gc-forever', k.h_pub, k.signature_validity_end," + " '\\x00'::BYTEA" + " FROM merchant_fountains f" + " CROSS JOIN merchant_token_family_keys k" + " WHERE f.fountain_id='gc-fountain'" + " AND k.h_pub=decode(repeat('05',64),'hex');" + /* One request spanning an expired and an indefinite key. */ + "INSERT INTO merchant_fountain_withdraw_sigs" + " (fountain_serial, h_request, grant_index," + " token_index, token_family_slug, h_issue," + " signature_validity_end," + " token_blinded_signature)" + " SELECT f.fountain_serial," + " decode(repeat('06',64),'hex')," + " CASE WHEN k.signature_validity_end=1000000" + " THEN 0 ELSE 1 END, 0," + " tf.slug, k.h_pub, k.signature_validity_end," + " '\\x00'::BYTEA" + " FROM merchant_fountains f" + " CROSS JOIN merchant_token_family_keys k" + " JOIN merchant_token_families tf USING(token_family_serial)" + " WHERE f.fountain_id='gc-fountain'" + " AND k.h_pub IN (decode(repeat('04',64),'hex')," + " decode(repeat('05',64),'hex'));" + /* The same hash in another fountain is independent. */ + "INSERT INTO merchant_fountains" + " (fountain_id, h_fountain_secret, description, poll_freq)" + " VALUES ('gc-other-fountain'," + " decode(repeat('07',64),'hex'), 'GC test', 60000000);" + "INSERT INTO merchant_fountain_withdraw_sigs" + " (fountain_serial, h_request, grant_index, token_index," + " token_family_slug, h_issue, signature_validity_end," + " token_blinded_signature)" + " SELECT f.fountain_serial, saved.h_request," + " saved.grant_index, saved.token_index," + " saved.token_family_slug, saved.h_issue," + " saved.signature_validity_end," + " saved.token_blinded_signature" + " FROM merchant_fountains f" + " CROSS JOIN merchant_fountain_withdraw_sigs saved" + " WHERE f.fountain_id='gc-other-fountain'" + " AND saved.h_request=decode(repeat('06',64),'hex')" + " AND saved.grant_index=0;")); + TEST_COND_RET_ON_FAIL (GNUNET_OK == TALER_MERCHANTDB_gc (pg), + "Fountain garbage collection failed\n"); + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_RET_ON_FAIL (exec_sql ( + "DO $$ BEGIN" + " IF (SELECT count(*)" + " FROM merchant_fountain_withdrawals fw" + " JOIN merchant_token_families tf" + " USING(token_family_serial)" + " WHERE tf.slug='gc-finite') <> 0 THEN" + " RAISE EXCEPTION" + " 'Expired fountain counter survived GC';" + " END IF;" + " IF (SELECT count(*)" + " FROM merchant_fountain_withdrawals fw" + " JOIN merchant_token_families tf" + " USING(token_family_serial)" + " WHERE tf.slug='gc-forever'" + " AND num_withdrawn=1) <> 1 THEN" + " RAISE EXCEPTION" + " 'Indefinite fountain counter was lost';" + " END IF;" + " IF (SELECT count(*)" + " FROM merchant_fountain_withdraw_sigs fws" + " WHERE fws.h_request=decode(repeat('02',64),'hex')) <> 0" + " THEN" + " RAISE EXCEPTION" + " 'Expired withdrawal signature survived GC';" + " END IF;" + " IF (SELECT count(*)" + " FROM merchant_fountain_withdraw_sigs fws" + " WHERE fws.h_request=decode(repeat('03',64),'hex')) <> 1 THEN" + " RAISE EXCEPTION" + " 'Replayable withdrawal signature was collected';" + " END IF;" + " IF (SELECT count(*)" + " FROM merchant_fountain_withdraw_sigs fws" + " JOIN merchant_fountains f USING(fountain_serial)" + " WHERE f.fountain_id='gc-fountain'" + " AND fws.h_request=decode(repeat('06',64),'hex')) <> 2 THEN" + " RAISE EXCEPTION 'Mixed-expiry replay was partially collected';" + " END IF;" + " IF EXISTS (SELECT FROM merchant_fountain_withdraw_sigs" + " JOIN merchant_fountains USING(fountain_serial)" + " WHERE fountain_id='gc-other-fountain') THEN" + " RAISE EXCEPTION 'Replay expiry was shared across fountains';" + " END IF;" + " END $$;" + /* Deleting a family must not truncate a saved response. */ + "DELETE FROM merchant_token_families WHERE slug='gc-finite';" + "DO $$ BEGIN" + " IF (SELECT count(*) FROM merchant_fountain_withdraw_sigs" + " WHERE h_request=decode(repeat('06',64),'hex')) <> 2 THEN" + " RAISE EXCEPTION 'Family deletion truncated replay';" + " END IF;" + " END $$;")); + TEST_COND_RET_ON_FAIL (GNUNET_OK == TALER_MERCHANTDB_gc (pg), + "Fountain garbage collection failed\n"); + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_RET_ON_FAIL (exec_sql ( + "DO $$ BEGIN" + " IF (SELECT count(*) FROM merchant_fountain_withdraw_sigs" + " WHERE h_request=decode(repeat('06',64),'hex')) <> 2 THEN" + " RAISE EXCEPTION 'GC truncated replay after family deletion';" + " END IF;" + " END $$;" + /* Simulate the last saved expiry passing. */ + "UPDATE merchant_fountain_withdraw_sigs" + " SET signature_validity_end=1000000" + " WHERE h_issue=decode(repeat('05',64),'hex');")); + TEST_COND_RET_ON_FAIL (GNUNET_OK == TALER_MERCHANTDB_gc (pg), + "Fountain garbage collection failed\n"); + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_RET_ON_FAIL (exec_sql ( + "DO $$ BEGIN" + " IF EXISTS (SELECT FROM merchant_fountain_withdraw_sigs" + " JOIN merchant_fountains USING(fountain_serial)" + " WHERE fountain_id='gc-fountain') THEN" + " RAISE EXCEPTION 'Expired replay group survived GC';" + " END IF;" + " END $$;" + "DELETE FROM merchant_fountains" + " WHERE fountain_id IN ('gc-fountain','gc-other-fountain');" + "DELETE FROM merchant_token_families" + " WHERE slug IN ('gc-finite','gc-forever');")); + return 0; +} + + /* ********** Statistics ********** */ @@ -11253,6 +11585,7 @@ run_test_statistics (struct TestStatistics_Closure *cls) TEST_RET_ON_FAIL (test_insert_instance (&cls->instance, GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); TEST_RET_ON_FAIL (test_statistics_bucket_gc (&cls->instance)); + TEST_RET_ON_FAIL (test_fountain_gc (&cls->instance)); /* Every amount statistic bumped by a trigger in pg_triggers.sql must be registered in the per-instance schema. */ TEST_RET_ON_FAIL (test_statistics_amount_slug_collected ( diff --git a/src/backenddb/test_order_sequence_migrations.py b/src/backenddb/test_order_sequence_migrations.py @@ -306,6 +306,34 @@ class OrderSequenceMigrations(unittest.TestCase): self.assertEqual(empty.add_order(), 1) self.assertEqual(new.add_order(), 1) + def test_0048_adds_dd97_and_dd98_to_existing_and_new_instances(self): + db = self.database_before(47) + existing = db.add_instance(1) + db.apply_migration(47) + db.apply_migration(48) + new = db.add_instance(2) + for instance in (existing, new): + schema = instance.schema + self.assertEqual(db.sql(f""" + SELECT count(*) FROM information_schema.columns + WHERE table_schema='{schema}' + AND ((table_name='merchant_otp_devices' + AND column_name='otp_device_pub') + OR (table_name IN ('merchant_orders', 'merchant_contract_terms') + AND column_name='pos_challenge')) + """), "3") + self.assertEqual(db.sql(f""" + SELECT count(*) FROM information_schema.tables + WHERE table_schema='{schema}' AND table_name IN + ('merchant_fountains', 'merchant_fountain_grants', + 'merchant_fountain_withdrawals', 'merchant_fountain_withdraw_sigs') + """), "4") + self.assertEqual(db.sql(f""" + SELECT is_nullable FROM information_schema.columns + WHERE table_schema='{schema}' AND table_name='merchant_issued_tokens' + AND column_name='h_contract_terms' + """), "YES") + def test_0047_sequence_restart_rolls_back(self): db = self.database_before(47) orders = db.add_instance(1) diff --git a/src/include/merchant-database/all.h b/src/include/merchant-database/all.h @@ -9,6 +9,13 @@ #include "merchant-database/insert_kyc_failure.h" #include "merchant-database/insert_kyc_status.h" #include "merchant-database/do_insert_account.h" +#include "merchant-database/do_insert_fountain.h" +#include "merchant-database/do_update_fountain.h" +#include "merchant-database/do_fountain_withdraw.h" +#include "merchant-database/get_fountain.h" +#include "merchant-database/get_fountain_by_secret.h" +#include "merchant-database/iterate_fountains.h" +#include "merchant-database/iterate_fountain_grants.h" #include "merchant-database/get_exists_donau_instance.h" #include "merchant-database/get_missing_money_pot.h" #include "merchant-database/get_report_by_token.h" @@ -23,6 +30,7 @@ #include "merchant-database/delete_login_token.h" #include "merchant-database/delete_login_token_by_serial.h" #include "merchant-database/delete_money_pot.h" +#include "merchant-database/delete_fountain.h" #include "merchant-database/delete_order.h" #include "merchant-database/delete_otp_device.h" #include "merchant-database/delete_pending_webhook.h" diff --git a/src/include/merchant-database/delete_fountain.h b/src/include/merchant-database/delete_fountain.h @@ -0,0 +1,49 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/delete_fountain.h + * @brief implementation of the delete_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_DELETE_FOUNTAIN_H +#define MERCHANT_DATABASE_DELETE_FOUNTAIN_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Delete a fountain, disabling future withdrawals with its + * credential. Cascades to its grants and withdrawal counters; + * tokens already withdrawn remain valid. + * + * @param pg database context + * @param instance_id instance the fountain belongs to + * @param fountain_id public identifier of the fountain to delete + * @return database result code; + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if no fountain + * with @a fountain_id exists + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_delete_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id); + +#endif diff --git a/src/include/merchant-database/do_fountain_withdraw.h b/src/include/merchant-database/do_fountain_withdraw.h @@ -0,0 +1,65 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/do_fountain_withdraw.h + * @brief implementation of the do_fountain_withdraw function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_DO_FOUNTAIN_WITHDRAW_H +#define MERCHANT_DATABASE_DO_FOUNTAIN_WITHDRAW_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Atomically check and consume per-period withdrawal quota for a + * fountain. Each entry addresses one (token family, key slot) pair; + * entries must be distinct. All-or-nothing: if any entry has no + * matching grant or would exceed its limit, no quota is consumed and + * @a failed_index reports the offending entry. + * + * @param pg database context + * @param instance_id instance the fountain belongs to + * @param fountain_serial serial of the fountain withdrawing from + * @param num_entries number of entries in the parallel arrays + * @param token_family_serials token family of each entry + * @param slot_starts issue-key validity period start of each entry + * @param num_requested number of tokens requested by each entry + * @param[out] failed_index on failure, set to the index (into the + * input arrays) of the offending entry + * @param[out] no_grant set to true if the failed entry has no + * matching grant + * @param[out] exceeded set to true if the failed entry would exceed + * its tokens_per_period_limit + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_do_fountain_withdraw ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + uint64_t fountain_serial, + unsigned int num_entries, + const uint64_t token_family_serials[static num_entries], + const struct GNUNET_TIME_Timestamp slot_starts[static num_entries], + const uint64_t num_requested[static num_entries], + unsigned int *failed_index, + bool *no_grant, + bool *exceeded); + +#endif diff --git a/src/include/merchant-database/do_insert_fountain.h b/src/include/merchant-database/do_insert_fountain.h @@ -0,0 +1,91 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/do_insert_fountain.h + * @brief implementation of the do_insert_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_DO_INSERT_FOUNTAIN_H +#define MERCHANT_DATABASE_DO_INSERT_FOUNTAIN_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Withdrawal rights of a fountain for one token family (DD 98). + */ +struct TALER_MERCHANTDB_FountainGrant +{ + /** + * Slug of the token family this grant refers to. + */ + const char *token_family_slug; + + /** + * Maximum number of tokens blind-signed per issue-key + * validity period for this family. + */ + uint64_t tokens_per_period_limit; + + /** + * Number of tokens the wallet should aim to hold per period; + * at most @e tokens_per_period_limit. + */ + uint64_t tokens_per_period_stash; + + /** + * Number of issue-key slots ahead of the current one the + * wallet may withdraw tokens for. + */ + uint32_t key_window_size; +}; + + +/** + * Create a fountain with its grants. + * + * @param pg database context + * @param instance_id instance to create the fountain for + * @param fountain_id public identifier of the new fountain + * @param h_fountain_secret hash of the fountain's bearer credential + * @param description description of the fountain + * @param poll_freq how often wallets should re-poll the fountain info + * @param grants_len length of the @a grants array + * @param grants withdrawal rights of the fountain + * @param[out] unknown_slug set to the first token family slug in + * @a grants that does not exist (caller must free); the + * fountain is not created in that case + * @param[out] conflict set to true if a fountain with the same + * @a fountain_id or @a h_fountain_secret already exists + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_do_insert_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id, + const struct GNUNET_HashCode *h_fountain_secret, + const char *description, + struct GNUNET_TIME_Relative poll_freq, + unsigned int grants_len, + const struct TALER_MERCHANTDB_FountainGrant grants[static grants_len], + char **unknown_slug, + bool *conflict); + +#endif diff --git a/src/include/merchant-database/do_update_fountain.h b/src/include/merchant-database/do_update_fountain.h @@ -0,0 +1,65 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/do_update_fountain.h + * @brief implementation of the do_update_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_DO_UPDATE_FOUNTAIN_H +#define MERCHANT_DATABASE_DO_UPDATE_FOUNTAIN_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" +#include "merchant-database/do_insert_fountain.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Update a fountain. Fields that are NULL keep their previous + * value. If @a replace_grants is true, the fountain's grant set is + * fully replaced by @a grants; withdrawal counters are deliberately + * left untouched so consumed quota survives the replacement. + * + * @param pg database context + * @param instance_id instance the fountain belongs to + * @param fountain_id public identifier of the fountain to update + * @param description new description, or NULL to keep the previous one + * @param poll_freq new poll frequency, or NULL to keep the previous one + * @param replace_grants true to replace the grant set with @a grants + * @param grants_len length of the @a grants array + * @param grants new withdrawal rights of the fountain + * @param[out] not_found set to true if no fountain with + * @a fountain_id exists + * @param[out] unknown_slug set to the first token family slug in + * @a grants that does not exist (caller must free); the + * fountain is unchanged in that case + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_do_update_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id, + const char *description, + const struct GNUNET_TIME_Relative *poll_freq, + bool replace_grants, + unsigned int grants_len, + const struct TALER_MERCHANTDB_FountainGrant *grants, + bool *not_found, + char **unknown_slug); + +#endif diff --git a/src/include/merchant-database/get_fountain.h b/src/include/merchant-database/get_fountain.h @@ -0,0 +1,70 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/get_fountain.h + * @brief implementation of the get_fountain function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_GET_FOUNTAIN_H +#define MERCHANT_DATABASE_GET_FOUNTAIN_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Details about a fountain (DD 98), excluding its grants and + * (the hash of) its bearer credential. + */ +struct TALER_MERCHANTDB_FountainDetails +{ + /** + * Serial of the fountain in the database. + */ + uint64_t fountain_serial; + + /** + * Description of the fountain. Malloc'ed, caller must free. + */ + char *description; + + /** + * How often wallets should re-poll the fountain info. + */ + struct GNUNET_TIME_Relative poll_freq; +}; + + +/** + * Look up details of a fountain by its public identifier. + * + * @param pg database context + * @param instance_id instance the fountain belongs to + * @param fountain_id public identifier of the fountain + * @param[out] fd set to the fountain details on success, + * `fd->description` must be freed by the caller + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_get_fountain ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const char *fountain_id, + struct TALER_MERCHANTDB_FountainDetails *fd); + +#endif diff --git a/src/include/merchant-database/get_fountain_by_secret.h b/src/include/merchant-database/get_fountain_by_secret.h @@ -0,0 +1,53 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/get_fountain_by_secret.h + * @brief implementation of the get_fountain_by_secret function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_GET_FOUNTAIN_BY_SECRET_H +#define MERCHANT_DATABASE_GET_FOUNTAIN_BY_SECRET_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Look up a fountain by the hash of its bearer credential. Used to + * authenticate wallet requests to the public fountain endpoints; a + * result of #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS means the credential + * is unknown (or the fountain was deleted) and the request must be + * answered with HTTP 401. + * + * @param pg database context + * @param instance_id instance to look up the fountain in + * @param h_fountain_secret hash of the fountain's bearer credential + * @param[out] fountain_serial set to the serial of the fountain + * @param[out] poll_freq set to how often wallets should re-poll + * the fountain info + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_get_fountain_by_secret ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + const struct GNUNET_HashCode *h_fountain_secret, + uint64_t *fountain_serial, + struct GNUNET_TIME_Relative *poll_freq); + +#endif diff --git a/src/include/merchant-database/get_fountain_withdraw.h b/src/include/merchant-database/get_fountain_withdraw.h @@ -0,0 +1,68 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ +/** + * @file src/include/merchant-database/get_fountain_withdraw.h + * @brief look up the signatures of a completed fountain withdrawal + */ +#ifndef MERCHANT_DATABASE_GET_FOUNTAIN_WITHDRAW_H +#define MERCHANT_DATABASE_GET_FOUNTAIN_WITHDRAW_H +#include "merchantdb_lib.h" + +/** + * Called once per stored signature, ordered by grant and then by + * position within the grant, so the response can be rebuilt by + * appending. + * + * @param cls closure + * @param grant_index offset of the grant in the original request + * @param token_family_slug token family the grant refers to + * @param h_issue hash of the issue key the signature was made with + * @param blind_sig the blind signature handed out originally + */ +typedef void +(*TALER_MERCHANTDB_FountainWithdrawSigCallback)( + void *cls, + uint32_t grant_index, + const char *token_family_slug, + const struct TALER_TokenIssuePublicKeyHashP *h_issue, + const struct GNUNET_CRYPTO_BlindedSignature *blind_sig); + +/** + * Lock the fountain and hand back the signatures of an earlier + * withdrawal of the same request, if there was one. Must run in a + * READ COMMITTED transaction. The lock is held until commit or + * rollback; looking the signatures up after taking it is what makes a + * concurrent withdrawal of the same request visible here instead of + * being counted twice. + * + * @param pg database context with the instance selected + * @param fountain_serial authenticated fountain + * @param h_request hash of the request's grants array + * @param[out] not_found true if the fountain was deleted + * @param cb called for each stored signature, in response order + * @param cb_cls closure for @a cb + * @return database result code; no result if the request is new + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_get_fountain_withdraw ( + struct TALER_MERCHANTDB_PostgresContext *pg, + uint64_t fountain_serial, + const struct GNUNET_HashCode *h_request, + bool *not_found, + TALER_MERCHANTDB_FountainWithdrawSigCallback cb, + void *cb_cls); + +#endif diff --git a/src/include/merchant-database/get_token_family_key.h b/src/include/merchant-database/get_token_family_key.h @@ -67,6 +67,25 @@ struct TALER_MERCHANTDB_TokenFamilyKeyDetails /** + * Serialize key lookup and creation for a token family. Starts a READ COMMITTED + * transaction if none is active. The caller must retain the lock until lookup, + * lifetime extension and insertion are complete, and commit or roll back any + * transaction started here. Existing transactions remain owned by the caller. + * + * @param pg database context with the instance selected + * @param token_family_slug family to lock + * @param[out] started_transaction true if this call started a transaction, + * including on failure (the caller must roll it back) + * @return database result code; zero if the family does not exist + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_lock_token_family ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *token_family_slug, + bool *started_transaction); + + +/** * Lookup details about a particular token family key. The valid_after field * of the key has to be >= @e min_valid_after and < @e max_valid_after. * diff --git a/src/include/merchant-database/insert_fountain_withdraw_sig.h b/src/include/merchant-database/insert_fountain_withdraw_sig.h @@ -0,0 +1,50 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ +/** + * @file src/include/merchant-database/insert_fountain_withdraw_sig.h + * @brief record one blind signature of a completed fountain withdrawal + */ +#ifndef MERCHANT_DATABASE_INSERT_FOUNTAIN_WITHDRAW_SIG_H +#define MERCHANT_DATABASE_INSERT_FOUNTAIN_WITHDRAW_SIG_H +#include "merchantdb_lib.h" + +/** + * Store one blind signature of a withdrawal so that a repeated request + * can be answered from it. Must run in the same transaction as the + * quota consumption and the issued-token records, holding the fountain + * lock taken by TALER_MERCHANTDB_get_fountain_withdraw(). + * + * @param pg database context with the instance selected + * @param fountain_serial authenticated fountain + * @param h_request hash of the request's grants array + * @param grant_index offset of the grant in the request + * @param token_index offset of the signature within that grant + * @param h_issue_pub hash of the issue key used; its family slug and expiry + * are copied so replay survives deletion of the key or family + * @param blind_sig the blind signature handed to the wallet + * @return database result code; no result if the issue key is gone + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_insert_fountain_withdraw_sig ( + struct TALER_MERCHANTDB_PostgresContext *pg, + uint64_t fountain_serial, + const struct GNUNET_HashCode *h_request, + uint32_t grant_index, + uint32_t token_index, + const struct TALER_TokenIssuePublicKeyHashP *h_issue_pub, + const struct TALER_BlindedTokenIssueSignature *blind_sig); + +#endif diff --git a/src/include/merchant-database/insert_issued_token.h b/src/include/merchant-database/insert_issued_token.h @@ -29,7 +29,8 @@ struct TALER_MERCHANTDB_PostgresContext; /** * @param pg database context - * @param h_contract_terms hash of the contract the token was issued for + * @param h_contract_terms hash of the contract the token was issued for; + * NULL for tokens issued via a fountain (DD 98) * @param h_issue_pub hash of the token issue public key used to sign the issued token * @param blind_sig resulting blind token issue signature * @param[out] no_family set to true if the token family key is unknown, diff --git a/src/include/merchant-database/iterate_fountain_grants.h b/src/include/merchant-database/iterate_fountain_grants.h @@ -0,0 +1,71 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/iterate_fountain_grants.h + * @brief implementation of the iterate_fountain_grants function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_ITERATE_FOUNTAIN_GRANTS_H +#define MERCHANT_DATABASE_ITERATE_FOUNTAIN_GRANTS_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Typically called by `iterate_fountain_grants`. + * + * @param cls closure + * @param token_family_slug slug of the granted token family + * @param token_family_serial serial of the granted token family + * @param tokens_per_period_limit maximum withdrawals per issue-key + * validity period + * @param tokens_per_period_stash suggested number of tokens to hold + * per period + * @param key_window_size number of issue-key slots ahead the wallet + * may withdraw tokens for + */ +typedef void +(*TALER_MERCHANTDB_FountainGrantsCallback)( + void *cls, + const char *token_family_slug, + uint64_t token_family_serial, + uint64_t tokens_per_period_limit, + uint64_t tokens_per_period_stash, + uint32_t key_window_size); + + +/** + * Iterate over all grants of a fountain. + * + * @param pg database context + * @param instance_id instance the fountain belongs to + * @param fountain_serial serial of the fountain to iterate grants of + * @param cb function to call with each grant + * @param cb_cls closure for @a cb + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_iterate_fountain_grants ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + uint64_t fountain_serial, + TALER_MERCHANTDB_FountainGrantsCallback cb, + void *cb_cls); + +#endif diff --git a/src/include/merchant-database/iterate_fountains.h b/src/include/merchant-database/iterate_fountains.h @@ -0,0 +1,60 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/iterate_fountains.h + * @brief implementation of the iterate_fountains function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef MERCHANT_DATABASE_ITERATE_FOUNTAINS_H +#define MERCHANT_DATABASE_ITERATE_FOUNTAINS_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; + +/** + * Typically called by `iterate_fountains`. + * + * @param cls a `json_t *` JSON array to build + * @param fountain_id public identifier of the fountain + * @param description description of the fountain + */ +typedef void +(*TALER_MERCHANTDB_FountainsCallback)( + void *cls, + const char *fountain_id, + const char *description); + + +/** + * Iterate over all fountains of an instance. + * + * @param pg database context + * @param instance_id instance to iterate fountains of + * @param cb function to call with each fountain + * @param cb_cls closure for @a cb + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_iterate_fountains ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *instance_id, + TALER_MERCHANTDB_FountainsCallback cb, + void *cb_cls); + +#endif diff --git a/src/include/taler/merchant/delete-private-fountains-FOUNTAIN_ID.h b/src/include/taler/merchant/delete-private-fountains-FOUNTAIN_ID.h @@ -0,0 +1,111 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with + TALER; see the file COPYING.LGPL. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file src/include/taler/merchant/delete-private-fountains-FOUNTAIN_ID.h + * @brief C interface for the DELETE /private/fountains/$FOUNTAIN_ID endpoint (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef _TALER_MERCHANT__DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H +#define _TALER_MERCHANT__DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H + +#include <taler/merchant/common.h> + + +/** + * Handle for a DELETE /private/fountains/$FOUNTAIN_ID request. + */ +struct TALER_MERCHANT_DeletePrivateFountainHandle; + + +/** + * Response details for a DELETE /private/fountains/$FOUNTAIN_ID request. + */ +struct TALER_MERCHANT_DeletePrivateFountainResponse +{ + + /** + * HTTP response details. + */ + struct TALER_MERCHANT_HttpResponse hr; + +}; + + +#ifndef TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE +/** + * Type of the closure used by + * the #TALER_MERCHANT_DeletePrivateFountainCallback. + */ +#define TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE void +#endif /* TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE */ + +/** + * Callback for a DELETE /private/fountains/$FOUNTAIN_ID request. + * + * @param cls closure + * @param dfr response details + */ +typedef void +(*TALER_MERCHANT_DeletePrivateFountainCallback)( + TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cls, + const struct TALER_MERCHANT_DeletePrivateFountainResponse *dfr); + + +/** + * Set up DELETE /private/fountains/$FOUNTAIN_ID operation. + * Note that you must explicitly start the operation. + * + * @param ctx the context + * @param url base URL of the merchant backend + * @param fountain_id public identifier of the fountain to delete + * @return handle to operation + */ +struct TALER_MERCHANT_DeletePrivateFountainHandle * +TALER_MERCHANT_delete_private_fountain_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_id); + + +/** + * Start DELETE /private/fountains/$FOUNTAIN_ID operation. + * + * @param[in,out] dpfh operation to start + * @param cb function to call with the merchant's result + * @param cb_cls closure for @a cb + * @return status code, #TALER_EC_NONE on success + */ +enum TALER_ErrorCode +TALER_MERCHANT_delete_private_fountain_start ( + struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh, + TALER_MERCHANT_DeletePrivateFountainCallback cb, + TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls); + + +/** + * Cancel DELETE /private/fountains/$FOUNTAIN_ID operation. This + * function must not be called by clients after the callback has been + * invoked. + * + * @param[in] dpfh operation to cancel + */ +void +TALER_MERCHANT_delete_private_fountain_cancel ( + struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh); + + +#endif /* _TALER_MERCHANT__DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H */ diff --git a/src/include/taler/merchant/get-fountain-info.h b/src/include/taler/merchant/get-fountain-info.h @@ -0,0 +1,162 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with + TALER; see the file COPYING.LGPL. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file src/include/taler/merchant/get-fountain-info.h + * @brief C interface for the (public) GET /fountain/info endpoint (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef _TALER_MERCHANT__GET_FOUNTAIN_INFO_H +#define _TALER_MERCHANT__GET_FOUNTAIN_INFO_H + +#include <taler/merchant/common.h> +#include <taler/merchant/post-private-fountains.h> +#include <taler/taler_merchant_util.h> + + +/** + * Handle for a GET /fountain/info request. + */ +struct TALER_MERCHANT_GetFountainInfoHandle; + + +/** + * Grant of a fountain as seen by the wallet, including the token + * family metadata and issue keys needed to prepare blinded envelopes. + */ +struct TALER_MERCHANT_FountainWalletGrant +{ + /** + * Basic grant parameters. + */ + struct TALER_MERCHANT_FountainGrant grant; + + /** + * Token family metadata with the currently valid (and, within the + * grant's key window, upcoming) issue public keys, in increasing expiry + * order. Intervals can overlap; use a key's valid_after as the explicit + * valid_at of a withdrawal to select that key. + */ + struct TALER_MERCHANT_ContractTokenFamily token_family; +}; + + +/** + * Response details for a GET /fountain/info request. + */ +struct TALER_MERCHANT_GetFountainInfoResponse +{ + + /** + * HTTP response details. + */ + struct TALER_MERCHANT_HttpResponse hr; + + /** + * Details depending on the HTTP status. + */ + union + { + /** + * Details on #MHD_HTTP_OK. + */ + struct + { + /** + * How often the wallet should re-poll this endpoint. + */ + struct GNUNET_TIME_Relative poll_freq; + + /** + * Array of grants of the fountain. + */ + const struct TALER_MERCHANT_FountainWalletGrant *grants; + + /** + * Length of the @e grants array. + */ + unsigned int grants_len; + } ok; + } details; + +}; + + +#ifndef TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE +/** + * Type of the closure used by + * the #TALER_MERCHANT_GetFountainInfoCallback. + */ +#define TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE void +#endif /* TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE */ + +/** + * Callback for a GET /fountain/info request. + * + * @param cls closure + * @param fir response details + */ +typedef void +(*TALER_MERCHANT_GetFountainInfoCallback)( + TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cls, + const struct TALER_MERCHANT_GetFountainInfoResponse *fir); + + +/** + * Set up GET /fountain/info operation. The request is authenticated + * with the fountain's bearer credential in the Authorization header. + * Note that you must explicitly start the operation. + * + * @param ctx the context + * @param url base URL of the merchant backend + * @param fountain_secret the fountain's bearer credential + * (Crockford Base32) + * @return handle to operation + */ +struct TALER_MERCHANT_GetFountainInfoHandle * +TALER_MERCHANT_get_fountain_info_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_secret); + + +/** + * Start GET /fountain/info operation. + * + * @param[in,out] gfih operation to start + * @param cb function to call with the merchant's result + * @param cb_cls closure for @a cb + * @return status code, #TALER_EC_NONE on success + */ +enum TALER_ErrorCode +TALER_MERCHANT_get_fountain_info_start ( + struct TALER_MERCHANT_GetFountainInfoHandle *gfih, + TALER_MERCHANT_GetFountainInfoCallback cb, + TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cb_cls); + + +/** + * Cancel GET /fountain/info operation. This function must not be + * called by clients after the callback has been invoked. + * + * @param[in] gfih operation to cancel + */ +void +TALER_MERCHANT_get_fountain_info_cancel ( + struct TALER_MERCHANT_GetFountainInfoHandle *gfih); + + +#endif /* _TALER_MERCHANT__GET_FOUNTAIN_INFO_H */ diff --git a/src/include/taler/merchant/get-private-fountains-FOUNTAIN_ID.h b/src/include/taler/merchant/get-private-fountains-FOUNTAIN_ID.h @@ -0,0 +1,143 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with + TALER; see the file COPYING.LGPL. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file src/include/taler/merchant/get-private-fountains-FOUNTAIN_ID.h + * @brief C interface for the GET /private/fountains/$FOUNTAIN_ID endpoint (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H +#define _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H + +#include <taler/merchant/common.h> +#include <taler/merchant/post-private-fountains.h> + + +/** + * Handle for a GET /private/fountains/$FOUNTAIN_ID request. + */ +struct TALER_MERCHANT_GetPrivateFountainHandle; + + +/** + * Response details for a GET /private/fountains/$FOUNTAIN_ID request. + */ +struct TALER_MERCHANT_GetPrivateFountainResponse +{ + + /** + * HTTP response details. + */ + struct TALER_MERCHANT_HttpResponse hr; + + /** + * Details depending on the HTTP status. + */ + union + { + /** + * Details on #MHD_HTTP_OK. + */ + struct + { + /** + * Description of the fountain. + */ + const char *description; + + /** + * How often wallets should re-poll the fountain info. + */ + struct GNUNET_TIME_Relative poll_freq; + + /** + * Array of grants of the fountain. + */ + const struct TALER_MERCHANT_FountainGrant *grants; + + /** + * Length of the @e grants array. + */ + unsigned int grants_len; + } ok; + } details; + +}; + + +#ifndef TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE +/** + * Type of the closure used by + * the #TALER_MERCHANT_GetPrivateFountainCallback. + */ +#define TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE void +#endif /* TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE */ + +/** + * Callback for a GET /private/fountains/$FOUNTAIN_ID request. + * + * @param cls closure + * @param gfr response details + */ +typedef void +(*TALER_MERCHANT_GetPrivateFountainCallback)( + TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cls, + const struct TALER_MERCHANT_GetPrivateFountainResponse *gfr); + + +/** + * Set up GET /private/fountains/$FOUNTAIN_ID operation. + * Note that you must explicitly start the operation. + * + * @param ctx the context + * @param url base URL of the merchant backend + * @param fountain_id public identifier of the fountain to inspect + * @return handle to operation + */ +struct TALER_MERCHANT_GetPrivateFountainHandle * +TALER_MERCHANT_get_private_fountain_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_id); + + +/** + * Start GET /private/fountains/$FOUNTAIN_ID operation. + * + * @param[in,out] gpfh operation to start + * @param cb function to call with the merchant's result + * @param cb_cls closure for @a cb + * @return status code, #TALER_EC_NONE on success + */ +enum TALER_ErrorCode +TALER_MERCHANT_get_private_fountain_start ( + struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh, + TALER_MERCHANT_GetPrivateFountainCallback cb, + TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls); + + +/** + * Cancel GET /private/fountains/$FOUNTAIN_ID operation. This function + * must not be called by clients after the callback has been invoked. + * + * @param[in] gpfh operation to cancel + */ +void +TALER_MERCHANT_get_private_fountain_cancel ( + struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh); + + +#endif /* _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H */ diff --git a/src/include/taler/merchant/get-private-fountains.h b/src/include/taler/merchant/get-private-fountains.h @@ -0,0 +1,147 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with + TALER; see the file COPYING.LGPL. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file src/include/taler/merchant/get-private-fountains.h + * @brief C interface for the GET /private/fountains endpoint (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_H +#define _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_H + +#include <taler/merchant/common.h> + + +/** + * Handle for a GET /private/fountains request. + */ +struct TALER_MERCHANT_GetPrivateFountainsHandle; + + +/** + * Summary of a fountain in a fountain listing. + */ +struct TALER_MERCHANT_FountainEntry +{ + /** + * Public identifier of the fountain. + */ + const char *fountain_id; + + /** + * Description of the fountain. + */ + const char *description; +}; + + +/** + * Response details for a GET /private/fountains request. + */ +struct TALER_MERCHANT_GetPrivateFountainsResponse +{ + + /** + * HTTP response details. + */ + struct TALER_MERCHANT_HttpResponse hr; + + /** + * Details depending on the HTTP status. + */ + union + { + /** + * Details on #MHD_HTTP_OK. + */ + struct + { + /** + * Array of fountains. + */ + const struct TALER_MERCHANT_FountainEntry *fountains; + + /** + * Length of the @e fountains array. + */ + unsigned int fountains_len; + } ok; + } details; + +}; + + +#ifndef TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE +/** + * Type of the closure used by + * the #TALER_MERCHANT_GetPrivateFountainsCallback. + */ +#define TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE void +#endif /* TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE */ + +/** + * Callback for a GET /private/fountains request. + * + * @param cls closure + * @param gfr response details + */ +typedef void +(*TALER_MERCHANT_GetPrivateFountainsCallback)( + TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cls, + const struct TALER_MERCHANT_GetPrivateFountainsResponse *gfr); + + +/** + * Set up GET /private/fountains operation. + * Note that you must explicitly start the operation. + * + * @param ctx the context + * @param url base URL of the merchant backend + * @return handle to operation + */ +struct TALER_MERCHANT_GetPrivateFountainsHandle * +TALER_MERCHANT_get_private_fountains_create ( + struct GNUNET_CURL_Context *ctx, + const char *url); + + +/** + * Start GET /private/fountains operation. + * + * @param[in,out] gpfh operation to start + * @param cb function to call with the merchant's result + * @param cb_cls closure for @a cb + * @return status code, #TALER_EC_NONE on success + */ +enum TALER_ErrorCode +TALER_MERCHANT_get_private_fountains_start ( + struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh, + TALER_MERCHANT_GetPrivateFountainsCallback cb, + TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls); + + +/** + * Cancel GET /private/fountains operation. This function must not be + * called by clients after the callback has been invoked. + * + * @param[in] gpfh operation to cancel + */ +void +TALER_MERCHANT_get_private_fountains_cancel ( + struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh); + + +#endif /* _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_H */ diff --git a/src/include/taler/merchant/meson.build b/src/include/taler/merchant/meson.build @@ -5,6 +5,7 @@ talermerchantinclude_HEADERS = [ 'delete-management-instances-INSTANCE.h', 'delete-private-accounts-H_WIRE.h', 'delete-private-donau-DONAU_SERIAL.h', + 'delete-private-fountains-FOUNTAIN_ID.h', 'delete-private-orders-ORDER_ID.h', 'delete-private-otp-devices-DEVICE_ID.h', 'delete-private-products-PRODUCT_ID.h', @@ -15,6 +16,7 @@ talermerchantinclude_HEADERS = [ 'delete-private-units-UNIT.h', 'delete-private-webhooks-WEBHOOK_ID.h', 'get-config.h', + 'get-fountain-info.h', 'get-management-instances.h', 'get-management-instances-INSTANCE.h', 'get-private-kyc.h', @@ -24,6 +26,8 @@ talermerchantinclude_HEADERS = [ 'get-private-accounts.h', 'get-private-accounts-H_WIRE.h', 'get-private-donau.h', + 'get-private-fountains.h', + 'get-private-fountains-FOUNTAIN_ID.h', 'get-private-orders.h', 'get-private-orders-ORDER_ID.h', 'get-private-otp-devices.h', @@ -42,12 +46,14 @@ talermerchantinclude_HEADERS = [ 'get-templates-TEMPLATE_ID.h', 'patch-management-instances-INSTANCE.h', 'patch-private-accounts-H_WIRE.h', + 'patch-private-fountains-FOUNTAIN_ID.h', 'patch-private-otp-devices-DEVICE_ID.h', 'patch-private-orders-ORDER_ID-forget.h', 'patch-private-products-PRODUCT_ID.h', 'patch-private-templates-TEMPLATE_ID.h', 'patch-private-units-UNIT.h', 'patch-private-webhooks-WEBHOOK_ID.h', + 'post-fountain-withdraw.h', 'post-management-instances.h', 'post-management-instances-INSTANCE-auth.h', 'post-orders-ORDER_ID-abort.h', @@ -58,6 +64,7 @@ talermerchantinclude_HEADERS = [ 'post-private-accounts.h', 'post-private-categories.h', 'post-private-donau.h', + 'post-private-fountains.h', 'post-private-orders.h', 'post-private-orders-ORDER_ID-refund.h', 'post-private-orders-ORDER_ID-refund-external.h', @@ -82,5 +89,3 @@ foreach h : talermerchantinclude_HEADERS ) endforeach - - diff --git a/src/include/taler/merchant/patch-private-fountains-FOUNTAIN_ID.h b/src/include/taler/merchant/patch-private-fountains-FOUNTAIN_ID.h @@ -0,0 +1,295 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with + TALER; see the file COPYING.LGPL. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file src/include/taler/merchant/patch-private-fountains-FOUNTAIN_ID.h + * @brief C interface for the PATCH /private/fountains/$FOUNTAIN_ID endpoint (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef _TALER_MERCHANT__PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H +#define _TALER_MERCHANT__PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H + +#include <taler/merchant/common.h> +#include <taler/merchant/post-private-fountains.h> + + +/** + * Handle for a PATCH /private/fountains/$FOUNTAIN_ID request. + */ +struct TALER_MERCHANT_PatchPrivateFountainHandle; + + +/** + * Response details for a PATCH /private/fountains/$FOUNTAIN_ID request. + */ +struct TALER_MERCHANT_PatchPrivateFountainResponse +{ + + /** + * HTTP response details. + */ + struct TALER_MERCHANT_HttpResponse hr; + +}; + + +/** + * Possible options we can set for the + * PATCH /private/fountains/$FOUNTAIN_ID request. + */ +enum TALER_MERCHANT_PatchPrivateFountainOption +{ + /** + * End of list of options. + */ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_END = 0, + + /** + * Set a new description. + */ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION, + + /** + * Set a new poll frequency. + */ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ, + + /** + * Replace the grant set. + */ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS + +}; + + +/** + * Value for an option for the PATCH /private/fountains/$FOUNTAIN_ID + * request. + */ +struct TALER_MERCHANT_PatchPrivateFountainOptionValue +{ + + /** + * Type of the option being set. + */ + enum TALER_MERCHANT_PatchPrivateFountainOption option; + + /** + * Specific option value. + */ + union + { + + /** + * Value if @e option is + * #TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION. + */ + const char *description; + + /** + * Value if @e option is + * #TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ. + */ + struct GNUNET_TIME_Relative poll_freq; + + /** + * Value if @e option is + * #TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS. + */ + struct + { + /** + * Length of the @e grants array. + */ + unsigned int num_grants; + + /** + * New grant set of the fountain. + */ + const struct TALER_MERCHANT_FountainGrant *grants; + } grants; + + } details; + +}; + + +/** + * Set up PATCH /private/fountains/$FOUNTAIN_ID operation. + * Note that you must explicitly start the operation after + * possibly setting options. + * + * @param ctx the context + * @param url base URL of the merchant backend + * @param fountain_id public identifier of the fountain to modify + * @return handle to operation + */ +struct TALER_MERCHANT_PatchPrivateFountainHandle * +TALER_MERCHANT_patch_private_fountain_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_id); + + +/** + * Terminate the list of the options. + * + * @return the terminating object of struct TALER_MERCHANT_PatchPrivateFountainOptionValue + */ +#define TALER_MERCHANT_patch_private_fountain_option_end_() \ + (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \ + { \ + .option = TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_END \ + } + +/** + * Set a new description. + * + * @param d the new description + * @return representation of the option as a struct TALER_MERCHANT_PatchPrivateFountainOptionValue + */ +#define TALER_MERCHANT_patch_private_fountain_option_description(d) \ + (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \ + { \ + .option = \ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION, \ + .details.description = (d) \ + } + +/** + * Set a new poll frequency. + * + * @param p the new poll frequency + * @return representation of the option as a struct TALER_MERCHANT_PatchPrivateFountainOptionValue + */ +#define TALER_MERCHANT_patch_private_fountain_option_poll_freq(p) \ + (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \ + { \ + .option = \ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ, \ + .details.poll_freq = (p) \ + } + +/** + * Replace the grant set. + * + * @param n length of the @a g array + * @param g the new grant set + * @return representation of the option as a struct TALER_MERCHANT_PatchPrivateFountainOptionValue + */ +#define TALER_MERCHANT_patch_private_fountain_option_grants(n,g) \ + (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \ + { \ + .option = \ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS, \ + .details.grants.num_grants = (n), \ + .details.grants.grants = (g) \ + } + + +/** + * Set the requested options for the operation. + * + * If any option fail other options may be or may be not applied. + * + * @param ppfh the request to set the options for + * @param num_options length of the @a options array + * @param options an array of options + * @return #GNUNET_OK on success, + * #GNUNET_NO on failure, + * #GNUNET_SYSERR on internal error + */ +enum GNUNET_GenericReturnValue +TALER_MERCHANT_patch_private_fountain_set_options_ ( + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh, + unsigned int num_options, + const struct TALER_MERCHANT_PatchPrivateFountainOptionValue *options); + + +/** + * Set the requested options for the operation. + * + * It should be used with helpers that create required options, for example: + * + * TALER_MERCHANT_patch_private_fountain_set_options ( + * ppfh, + * TALER_MERCHANT_patch_private_fountain_option_description ("new")); + * + * @param ppfh the request to set the options for + * @param ... the list of the options, each option must be created + * by helpers TALER_MERCHANT_patch_private_fountain_option_NAME(VALUE) + * @return #GNUNET_OK on success, + * #GNUNET_NO on failure, + * #GNUNET_SYSERR on internal error + */ +#define TALER_MERCHANT_patch_private_fountain_set_options(ppfh,...) \ + TALER_MERCHANT_patch_private_fountain_set_options_ ( \ + ppfh, \ + TALER_MERCHANT_COMMON_OPTIONS_ARRAY_MAX_SIZE, \ + ((const struct TALER_MERCHANT_PatchPrivateFountainOptionValue[]) \ + {__VA_ARGS__, TALER_MERCHANT_patch_private_fountain_option_end_ () \ + } \ + )) + + +#ifndef TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE +/** + * Type of the closure used by + * the #TALER_MERCHANT_PatchPrivateFountainCallback. + */ +#define TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE void +#endif /* TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE */ + +/** + * Callback for a PATCH /private/fountains/$FOUNTAIN_ID request. + * + * @param cls closure + * @param pfr response details + */ +typedef void +(*TALER_MERCHANT_PatchPrivateFountainCallback)( + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cls, + const struct TALER_MERCHANT_PatchPrivateFountainResponse *pfr); + + +/** + * Start PATCH /private/fountains/$FOUNTAIN_ID operation. + * + * @param[in,out] ppfh operation to start + * @param cb function to call with the merchant's result + * @param cb_cls closure for @a cb + * @return status code, #TALER_EC_NONE on success + */ +enum TALER_ErrorCode +TALER_MERCHANT_patch_private_fountain_start ( + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh, + TALER_MERCHANT_PatchPrivateFountainCallback cb, + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls); + + +/** + * Cancel PATCH /private/fountains/$FOUNTAIN_ID operation. This + * function must not be called by clients after the callback has been + * invoked. + * + * @param[in] ppfh operation to cancel + */ +void +TALER_MERCHANT_patch_private_fountain_cancel ( + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh); + + +#endif /* _TALER_MERCHANT__PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H */ diff --git a/src/include/taler/merchant/post-fountain-withdraw.h b/src/include/taler/merchant/post-fountain-withdraw.h @@ -0,0 +1,199 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with + TALER; see the file COPYING.LGPL. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file src/include/taler/merchant/post-fountain-withdraw.h + * @brief C interface for the (public) POST /fountain/withdraw endpoint (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef _TALER_MERCHANT__POST_FOUNTAIN_WITHDRAW_H +#define _TALER_MERCHANT__POST_FOUNTAIN_WITHDRAW_H + +#include <taler/merchant/common.h> + + +/** + * Handle for a POST /fountain/withdraw request. + */ +struct TALER_MERCHANT_PostFountainWithdrawHandle; + + +/** + * One entry of a fountain withdraw request: envelopes for one + * token family and issue key slot. + */ +struct TALER_MERCHANT_FountainWithdrawEntry +{ + /** + * Slug of the token family to withdraw from. + */ + const char *token_family_slug; + + /** + * Desired token validity time. Use an advertised key's + * signature_validity_start to select that key even when an earlier key + * overlaps it. Otherwise the first advertised key covering the time is + * selected. Use a zero timestamp to omit and select using the current + * time. No key may start after now + key_window_size * duration. + */ + struct GNUNET_TIME_Timestamp valid_at; + + /** + * Number of envelopes in @e envelopes. + */ + unsigned int num_envelopes; + + /** + * Blinded token envelopes to have signed. + */ + const struct TALER_TokenEnvelope *envelopes; +}; + + +/** + * Result for one entry of a fountain withdraw request. + */ +struct TALER_MERCHANT_FountainWithdrawResult +{ + /** + * Slug of the token family the signatures belong to. + */ + const char *token_family_slug; + + /** + * Hash of the issue public key that was used. + */ + struct TALER_TokenIssuePublicKeyHashP h_issue; + + /** + * Number of signatures in @e token_sigs; matches the number of + * envelopes of the corresponding request entry. + */ + unsigned int num_sigs; + + /** + * Blind signatures over the envelopes, in request order. + * The JSON token_sigs array uses the same blind_sig wrapper as + * the order payment response. + */ + const struct TALER_BlindedTokenIssueSignature *token_sigs; +}; + + +/** + * Response details for a POST /fountain/withdraw request. + */ +struct TALER_MERCHANT_PostFountainWithdrawResponse +{ + + /** + * HTTP response details. + */ + struct TALER_MERCHANT_HttpResponse hr; + + /** + * Details depending on the HTTP status. + */ + union + { + /** + * Details on #MHD_HTTP_OK. + */ + struct + { + /** + * Array of per-entry results, in request order. + */ + const struct TALER_MERCHANT_FountainWithdrawResult *results; + + /** + * Length of the @e results array. + */ + unsigned int results_len; + } ok; + } details; + +}; + + +#ifndef TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE +/** + * Type of the closure used by + * the #TALER_MERCHANT_PostFountainWithdrawCallback. + */ +#define TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE void +#endif /* TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE */ + +/** + * Callback for a POST /fountain/withdraw request. + * + * @param cls closure + * @param fwr response details + */ +typedef void +(*TALER_MERCHANT_PostFountainWithdrawCallback)( + TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cls, + const struct TALER_MERCHANT_PostFountainWithdrawResponse *fwr); + + +/** + * Set up POST /fountain/withdraw operation. + * Note that you must explicitly start the operation. + * + * @param ctx the context + * @param url base URL of the merchant backend + * @param fountain_secret the fountain's bearer credential + * (Crockford Base32) + * @param num_entries length of the @a entries array + * @param entries withdrawal entries + * @return handle to operation + */ +struct TALER_MERCHANT_PostFountainWithdrawHandle * +TALER_MERCHANT_post_fountain_withdraw_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_secret, + unsigned int num_entries, + const struct TALER_MERCHANT_FountainWithdrawEntry *entries); + + +/** + * Start POST /fountain/withdraw operation. + * + * @param[in,out] pfwh operation to start + * @param cb function to call with the merchant's result + * @param cb_cls closure for @a cb + * @return status code, #TALER_EC_NONE on success + */ +enum TALER_ErrorCode +TALER_MERCHANT_post_fountain_withdraw_start ( + struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh, + TALER_MERCHANT_PostFountainWithdrawCallback cb, + TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cb_cls); + + +/** + * Cancel POST /fountain/withdraw operation. This function must not + * be called by clients after the callback has been invoked. + * + * @param[in] pfwh operation to cancel + */ +void +TALER_MERCHANT_post_fountain_withdraw_cancel ( + struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh); + + +#endif /* _TALER_MERCHANT__POST_FOUNTAIN_WITHDRAW_H */ diff --git a/src/include/taler/merchant/post-private-fountains.h b/src/include/taler/merchant/post-private-fountains.h @@ -0,0 +1,173 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with + TALER; see the file COPYING.LGPL. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file src/include/taler/merchant/post-private-fountains.h + * @brief C interface for the POST /private/fountains endpoint (DD 98) + * @author Bohdan Potuzhnyi + */ +#ifndef _TALER_MERCHANT__POST_PRIVATE_FOUNTAINS_H +#define _TALER_MERCHANT__POST_PRIVATE_FOUNTAINS_H + +#include <taler/merchant/common.h> + + +/** + * Withdrawal rights of a fountain for one token family (DD 98). + */ +struct TALER_MERCHANT_FountainGrant +{ + /** + * Slug of the token family this grant refers to. + */ + const char *token_family_slug; + + /** + * Maximum number of tokens blind-signed per issue-key validity + * period for this family. + */ + uint64_t tokens_per_period_limit; + + /** + * Number of tokens the wallet should aim to hold per period; + * at most @e tokens_per_period_limit. + */ + uint64_t tokens_per_period_stash; + + /** + * Maximum number of future issue keys in addition to the current key. + * No key may start after now + key_window_size * duration. + * Each successive key is selected just after the previous key expires, + * using the token family's normal duration and rounding rules. The + * advertised window can be shorter if family bounds or rounding prevent + * further coverage. Zero allows only the current key. + */ + uint32_t key_window_size; +}; + + +/** + * Handle for a POST /private/fountains request. + */ +struct TALER_MERCHANT_PostPrivateFountainsHandle; + + +/** + * Response details for a POST /private/fountains request. + */ +struct TALER_MERCHANT_PostPrivateFountainsResponse +{ + + /** + * HTTP response details. + */ + struct TALER_MERCHANT_HttpResponse hr; + + /** + * Details depending on the HTTP status. + */ + union + { + /** + * Details on #MHD_HTTP_OK. + */ + struct + { + /** + * Public identifier of the new fountain. + */ + const char *fountain_id; + + /** + * Bearer credential of the new fountain, Crockford Base32. + * Returned exactly once; the backend stores only its hash. + */ + const char *fountain_secret; + } ok; + } details; + +}; + + +#ifndef TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE +/** + * Type of the closure used by + * the #TALER_MERCHANT_PostPrivateFountainsCallback. + */ +#define TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE void +#endif /* TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE */ + +/** + * Callback for a POST /private/fountains request. + * + * @param cls closure + * @param pfr response details + */ +typedef void +(*TALER_MERCHANT_PostPrivateFountainsCallback)( + TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cls, + const struct TALER_MERCHANT_PostPrivateFountainsResponse *pfr); + + +/** + * Set up POST /private/fountains operation. + * Note that you must explicitly start the operation. + * + * @param ctx the context + * @param url base URL of the merchant backend + * @param description description of the fountain + * @param poll_freq how often wallets should re-poll the fountain info + * @param num_grants length of the @a grants array + * @param grants withdrawal rights of the fountain + * @return handle to operation + */ +struct TALER_MERCHANT_PostPrivateFountainsHandle * +TALER_MERCHANT_post_private_fountains_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *description, + struct GNUNET_TIME_Relative poll_freq, + unsigned int num_grants, + const struct TALER_MERCHANT_FountainGrant *grants); + + +/** + * Start POST /private/fountains operation. + * + * @param[in,out] ppfh operation to start + * @param cb function to call with the merchant's result + * @param cb_cls closure for @a cb + * @return status code, #TALER_EC_NONE on success + */ +enum TALER_ErrorCode +TALER_MERCHANT_post_private_fountains_start ( + struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh, + TALER_MERCHANT_PostPrivateFountainsCallback cb, + TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls); + + +/** + * Cancel POST /private/fountains operation. This function must not be + * called by clients after the callback has been invoked. + * + * @param[in] ppfh operation to cancel + */ +void +TALER_MERCHANT_post_private_fountains_cancel ( + struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh); + + +#endif /* _TALER_MERCHANT__POST_PRIVATE_FOUNTAINS_H */ diff --git a/src/include/taler/taler_merchant_service.h b/src/include/taler/taler_merchant_service.h @@ -75,6 +75,13 @@ #include <taler/merchant/post-private-accounts.h> #include <taler/merchant/patch-private-accounts-H_WIRE.h> #include <taler/merchant/delete-private-accounts-H_WIRE.h> +#include <taler/merchant/get-private-fountains.h> +#include <taler/merchant/get-private-fountains-FOUNTAIN_ID.h> +#include <taler/merchant/post-private-fountains.h> +#include <taler/merchant/patch-private-fountains-FOUNTAIN_ID.h> +#include <taler/merchant/delete-private-fountains-FOUNTAIN_ID.h> +#include <taler/merchant/get-fountain-info.h> +#include <taler/merchant/post-fountain-withdraw.h> #include <taler/merchant/get-private-otp-devices.h> #include <taler/merchant/get-private-otp-devices-DEVICE_ID.h> #include <taler/merchant/post-private-otp-devices.h> diff --git a/src/include/taler/taler_merchant_testing_lib.h b/src/include/taler/taler_merchant_testing_lib.h @@ -2320,6 +2320,62 @@ TALER_TESTING_cmd_merchant_post_tokenfamilies ( struct GNUNET_TIME_Relative rounding, const char *kind); +/* ****** Fountains (DD 98) ******* */ + +/** + * Define a "POST /private/fountains" CMD. + * + * @param label command label. + * @param merchant_url base URL of the merchant serving the request. + * @param http_status expected HTTP response code. + * @param description description of the fountain. + * @param poll_freq poll frequency of the fountain. + * @param num_grants length of the @a grants array. + * @param grants grants of the fountain; must remain valid for the + * lifetime of the command. + * @return the command. + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_fountains ( + const char *label, + const char *merchant_url, + unsigned int http_status, + const char *description, + struct GNUNET_TIME_Relative poll_freq, + unsigned int num_grants, + const struct TALER_MERCHANT_FountainGrant *grants); + + +/** + * Define a CMD simulating a wallet withdrawing tokens from a + * fountain: fetches GET /fountain/info, prepares blinded envelopes, + * runs POST /fountain/withdraw and unblinds and verifies the + * resulting tokens. The withdrawn tokens are offered via the + * indexed token_priv/token_issue_pub/token_issue_sig traits, so a + * pay command can spend them via its token reference. + * + * @param label command label. + * @param merchant_url base URL of the merchant serving the request. + * @param http_status expected HTTP response code of the withdraw + * request. + * @param fountain_reference label of the command offering the + * fountain secret (e.g. a "POST /private/fountains" command). + * @param token_family_slug slug of the token family to withdraw from. + * @param valid_at desired validity time of the tokens; zero for "now". + * @param num_tokens number of tokens to withdraw. + * @return the command. + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_fountain_withdraw ( + const char *label, + const char *merchant_url, + unsigned int http_status, + const char *fountain_reference, + const char *token_family_slug, + struct GNUNET_TIME_Timestamp valid_at, + unsigned int num_tokens); + + /* ****** Webhooks ******* */ @@ -2733,7 +2789,9 @@ TALER_TESTING_cmd_exec_donaukeyupdate (const char *label, op (summary, const char) \ op (token_family_slug, const char) \ op (token_family_duration, const struct GNUNET_TIME_Relative) \ - op (token_family_kind, const char) + op (token_family_kind, const char) \ + op (fountain_id, const char) \ + op (fountain_secret, const char) /** diff --git a/src/include/taler/taler_merchant_util.h b/src/include/taler/taler_merchant_util.h @@ -1861,8 +1861,25 @@ TALER_MERCHANT_spec_contract_choices ( /** + * Maximum number of issue keys accepted per token family by the shared JSON + * parser. This resource limit also applies to fountain-info responses. + */ +#define TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS 1024 + + +/** + * Maximum number of expected or trusted domains accepted per token family by + * the shared JSON parser. + */ +#define TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS 1024 + + +/** * Provide specification to parse given JSON array to token families in the * contract terms. All fields from @a families items are copied. + * Rejects keys and domain arrays above #TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS + * and #TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS, respectively, before allocating + * their storage. * * @param name name of the token families field in the JSON * @param[out] families where the token families array has to be written diff --git a/src/lib/merchant_api_common.h b/src/lib/merchant_api_common.h @@ -26,6 +26,14 @@ /** + * Resource limit for fountain response grants and total withdrawal signatures. + * Exceeds the backend's limits (192 grants and 64 envelopes), while bounding + * allocations even for malformed responses from an untrusted merchant. + */ +#define TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS 1024 + + +/** * Function called when we're done processing a * HTTP POST request to create an order. * diff --git a/src/lib/merchant_api_delete-private-fountains-FOUNTAIN_ID.c b/src/lib/merchant_api_delete-private-fountains-FOUNTAIN_ID.c @@ -0,0 +1,209 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as + published by the Free Software Foundation; either version 2.1, + or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with TALER; see the file COPYING.LGPL. + If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/lib/merchant_api_delete-private-fountains-FOUNTAIN_ID.c + * @brief Implementation of the DELETE /private/fountains/$FOUNTAIN_ID request + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> /* just for HTTP status codes */ +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <taler/merchant/delete-private-fountains-FOUNTAIN_ID.h> +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> + + +/** + * Handle for a DELETE /private/fountains/$FOUNTAIN_ID operation. + */ +struct TALER_MERCHANT_DeletePrivateFountainHandle +{ + /** + * Base URL of the merchant backend. + */ + char *base_url; + + /** + * The full URL for this request. + */ + char *url; + + /** + * Fountain identifier. + */ + char *fountain_id; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Function to call with the result. + */ + TALER_MERCHANT_DeletePrivateFountainCallback cb; + + /** + * Closure for @a cb. + */ + TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; +}; + + +/** + * Function called when we're done processing the + * HTTP DELETE /private/fountains/$FOUNTAIN_ID request. + * + * @param cls the `struct TALER_MERCHANT_DeletePrivateFountainHandle` + * @param response_code HTTP response code, 0 on error + * @param response response body, NULL if not in JSON + */ +static void +handle_delete_fountain_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh = cls; + const json_t *json = response; + struct TALER_MERCHANT_DeletePrivateFountainResponse dfr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + dpfh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "DELETE /private/fountains/$ID completed with response code %u\n", + (unsigned int) response_code); + switch (response_code) + { + case 0: + dfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + case MHD_HTTP_NO_CONTENT: + break; + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + dfr.hr.ec = TALER_JSON_get_error_code (json); + dfr.hr.hint = TALER_JSON_get_error_hint (json); + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &dfr.hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u/%d\n", + (unsigned int) response_code, + (int) dfr.hr.ec); + GNUNET_break_op (0); + break; + } + dpfh->cb (dpfh->cb_cls, + &dfr); + TALER_MERCHANT_delete_private_fountain_cancel (dpfh); +} + + +struct TALER_MERCHANT_DeletePrivateFountainHandle * +TALER_MERCHANT_delete_private_fountain_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_id) +{ + struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh; + + dpfh = GNUNET_new (struct TALER_MERCHANT_DeletePrivateFountainHandle); + dpfh->ctx = ctx; + dpfh->base_url = GNUNET_strdup (url); + dpfh->fountain_id = GNUNET_strdup (fountain_id); + return dpfh; +} + + +enum TALER_ErrorCode +TALER_MERCHANT_delete_private_fountain_start ( + struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh, + TALER_MERCHANT_DeletePrivateFountainCallback cb, + TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls) +{ + CURL *eh; + + dpfh->cb = cb; + dpfh->cb_cls = cb_cls; + { + char *path; + + GNUNET_asprintf (&path, + "private/fountains/%s", + dpfh->fountain_id); + dpfh->url = TALER_url_join (dpfh->base_url, + path, + NULL); + GNUNET_free (path); + } + if (NULL == dpfh->url) + return TALER_EC_GENERIC_CONFIGURATION_INVALID; + eh = TALER_MERCHANT_curl_easy_get_ (dpfh->url); + if (NULL == eh) + { + GNUNET_break (0); + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, + CURLOPT_CUSTOMREQUEST, + MHD_HTTP_METHOD_DELETE)); + dpfh->job = GNUNET_CURL_job_add (dpfh->ctx, + eh, + &handle_delete_fountain_finished, + dpfh); + if (NULL == dpfh->job) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + return TALER_EC_NONE; +} + + +void +TALER_MERCHANT_delete_private_fountain_cancel ( + struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh) +{ + if (NULL != dpfh->job) + { + GNUNET_CURL_job_cancel (dpfh->job); + dpfh->job = NULL; + } + GNUNET_free (dpfh->fountain_id); + GNUNET_free (dpfh->url); + GNUNET_free (dpfh->base_url); + GNUNET_free (dpfh); +} + + +/* end of merchant_api_delete-private-fountains-FOUNTAIN_ID.c */ diff --git a/src/lib/merchant_api_get-fountain-info.c b/src/lib/merchant_api_get-fountain-info.c @@ -0,0 +1,350 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as + published by the Free Software Foundation; either version 2.1, + or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with TALER; see the file COPYING.LGPL. + If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/lib/merchant_api_get-fountain-info.c + * @brief Implementation of the (public) GET /fountain/info request + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> /* just for HTTP status codes */ +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <taler/merchant/get-fountain-info.h> +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> + + +/** + * Handle for a GET /fountain/info operation. + */ +struct TALER_MERCHANT_GetFountainInfoHandle +{ + /** + * Base URL of the merchant backend. + */ + char *base_url; + + /** + * The full URL for this request. + */ + char *url; + + /** + * HTTP headers (Authorization) for the request. + */ + struct curl_slist *headers; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Function to call with the result. + */ + TALER_MERCHANT_GetFountainInfoCallback cb; + + /** + * Closure for @a cb. + */ + TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cb_cls; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; +}; + + +/** + * Parse the fountain info and invoke the callback. + * + * @param gfih operation handle + * @param[in,out] fir response to complete and pass to the callback + */ +static void +handle_ok (struct TALER_MERCHANT_GetFountainInfoHandle *gfih, + struct TALER_MERCHANT_GetFountainInfoResponse *fir) +{ + const json_t *jgrants; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_relative_time ("poll_freq", + &fir->details.ok.poll_freq), + GNUNET_JSON_spec_array_const ("grants", + &jgrants), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (fir->hr.reply, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + fir->hr.http_status = 0; + fir->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + gfih->cb (gfih->cb_cls, + fir); + return; + } + if (json_array_size (jgrants) > TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS) + { + fir->hr.http_status = 0; + fir->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + gfih->cb (gfih->cb_cls, + fir); + return; + } + { + unsigned int len = (unsigned int) json_array_size (jgrants); + struct TALER_MERCHANT_FountainWalletGrant *grants + = GNUNET_new_array (GNUNET_NZL (len), + struct TALER_MERCHANT_FountainWalletGrant); + unsigned int parsed = 0; + bool fail = false; + size_t idx; + json_t *jg; + + json_array_foreach ((json_t *) jgrants, idx, jg) + { + struct TALER_MERCHANT_FountainWalletGrant *wg = &grants[idx]; + const json_t *jfamily; + uint32_t window; + struct GNUNET_JSON_Specification ispec[] = { + GNUNET_JSON_spec_string ("token_family_slug", + &wg->grant.token_family_slug), + GNUNET_JSON_spec_uint64 ("tokens_per_period_limit", + &wg->grant.tokens_per_period_limit), + GNUNET_JSON_spec_uint64 ("tokens_per_period_stash", + &wg->grant.tokens_per_period_stash), + GNUNET_JSON_spec_uint32 ("key_window_size", + &window), + GNUNET_JSON_spec_object_const ("token_family", + &jfamily), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (jg, + ispec, + NULL, + NULL)) + { + GNUNET_break_op (0); + fail = true; + break; + } + wg->grant.key_window_size = window; + /* The single-family parser expects an object mapping slugs to + families (as in contract terms); wrap accordingly. */ + { + json_t *jwrap; + struct TALER_MERCHANT_ContractTokenFamily *families = NULL; + unsigned int families_len = 0; + struct GNUNET_JSON_Specification wspec[] = { + TALER_MERCHANT_spec_token_families (NULL, + &families, + &families_len), + GNUNET_JSON_spec_end () + }; + + jwrap = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_object_incref ( + wg->grant.token_family_slug, + (json_t *) jfamily)); + if ( (GNUNET_OK != + GNUNET_JSON_parse (jwrap, + wspec, + NULL, + NULL)) || + (1 != families_len) ) + { + GNUNET_break_op (0); + json_decref (jwrap); + for (unsigned int i = 0; i < families_len; i++) + TALER_MERCHANT_contract_token_family_free (&families[i]); + GNUNET_free (families); + fail = true; + break; + } + json_decref (jwrap); + wg->token_family = families[0]; + GNUNET_free (families); + } + parsed++; + } + if (fail) + { + for (unsigned int i = 0; i < parsed; i++) + TALER_MERCHANT_contract_token_family_free (&grants[i].token_family); + GNUNET_free (grants); + fir->hr.http_status = 0; + fir->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + gfih->cb (gfih->cb_cls, + fir); + return; + } + fir->details.ok.grants = grants; + fir->details.ok.grants_len = len; + gfih->cb (gfih->cb_cls, + fir); + for (unsigned int i = 0; i < len; i++) + TALER_MERCHANT_contract_token_family_free (&grants[i].token_family); + GNUNET_free (grants); + } +} + + +/** + * Function called when we're done processing the + * HTTP GET /fountain/info request. + * + * @param cls the `struct TALER_MERCHANT_GetFountainInfoHandle` + * @param response_code HTTP response code, 0 on error + * @param response response body, NULL if not in JSON + */ +static void +handle_get_fountain_info_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_GetFountainInfoHandle *gfih = cls; + const json_t *json = response; + struct TALER_MERCHANT_GetFountainInfoResponse fir = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + gfih->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "GET /fountain/info completed with response code %u\n", + (unsigned int) response_code); + switch (response_code) + { + case 0: + fir.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + case MHD_HTTP_OK: + handle_ok (gfih, + &fir); + TALER_MERCHANT_get_fountain_info_cancel (gfih); + return; + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + fir.hr.ec = TALER_JSON_get_error_code (json); + fir.hr.hint = TALER_JSON_get_error_hint (json); + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &fir.hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u/%d\n", + (unsigned int) response_code, + (int) fir.hr.ec); + GNUNET_break_op (0); + break; + } + gfih->cb (gfih->cb_cls, + &fir); + TALER_MERCHANT_get_fountain_info_cancel (gfih); +} + + +struct TALER_MERCHANT_GetFountainInfoHandle * +TALER_MERCHANT_get_fountain_info_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_secret) +{ + struct TALER_MERCHANT_GetFountainInfoHandle *gfih; + + gfih = GNUNET_new (struct TALER_MERCHANT_GetFountainInfoHandle); + gfih->ctx = ctx; + gfih->base_url = GNUNET_strdup (url); + { + char *hdr; + + GNUNET_asprintf (&hdr, + "%s: Bearer %s", + MHD_HTTP_HEADER_AUTHORIZATION, + fountain_secret); + gfih->headers = curl_slist_append (NULL, + hdr); + GNUNET_free (hdr); + } + return gfih; +} + + +enum TALER_ErrorCode +TALER_MERCHANT_get_fountain_info_start ( + struct TALER_MERCHANT_GetFountainInfoHandle *gfih, + TALER_MERCHANT_GetFountainInfoCallback cb, + TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cb_cls) +{ + CURL *eh; + + gfih->cb = cb; + gfih->cb_cls = cb_cls; + gfih->url = TALER_url_join (gfih->base_url, + "fountain/info", + NULL); + if (NULL == gfih->url) + return TALER_EC_GENERIC_CONFIGURATION_INVALID; + eh = TALER_MERCHANT_curl_easy_get_ (gfih->url); + if (NULL == eh) + { + GNUNET_break (0); + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + gfih->job = GNUNET_CURL_job_add2 (gfih->ctx, + eh, + gfih->headers, + &handle_get_fountain_info_finished, + gfih); + if (NULL == gfih->job) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + return TALER_EC_NONE; +} + + +void +TALER_MERCHANT_get_fountain_info_cancel ( + struct TALER_MERCHANT_GetFountainInfoHandle *gfih) +{ + if (NULL != gfih->job) + { + GNUNET_CURL_job_cancel (gfih->job); + gfih->job = NULL; + } + curl_slist_free_all (gfih->headers); + GNUNET_free (gfih->url); + GNUNET_free (gfih->base_url); + GNUNET_free (gfih); +} + + +/* end of merchant_api_get-fountain-info.c */ diff --git a/src/lib/merchant_api_get-private-fountains-FOUNTAIN_ID.c b/src/lib/merchant_api_get-private-fountains-FOUNTAIN_ID.c @@ -0,0 +1,299 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as + published by the Free Software Foundation; either version 2.1, + or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with TALER; see the file COPYING.LGPL. + If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/lib/merchant_api_get-private-fountains-FOUNTAIN_ID.c + * @brief Implementation of the GET /private/fountains/$FOUNTAIN_ID request + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> /* just for HTTP status codes */ +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <taler/merchant/get-private-fountains-FOUNTAIN_ID.h> +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> + + +/** + * Handle for a GET /private/fountains/$FOUNTAIN_ID operation. + */ +struct TALER_MERCHANT_GetPrivateFountainHandle +{ + /** + * Base URL of the merchant backend. + */ + char *base_url; + + /** + * The full URL for this request. + */ + char *url; + + /** + * Fountain identifier. + */ + char *fountain_id; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Function to call with the result. + */ + TALER_MERCHANT_GetPrivateFountainCallback cb; + + /** + * Closure for @a cb. + */ + TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; +}; + + +/** + * Parse the fountain details and invoke the callback. + * + * @param gpfh operation handle + * @param[in,out] gfr response to complete and pass to the callback + */ +static void +handle_ok (struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh, + struct TALER_MERCHANT_GetPrivateFountainResponse *gfr) +{ + const json_t *jgrants; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("description", + &gfr->details.ok.description), + GNUNET_JSON_spec_relative_time ("poll_freq", + &gfr->details.ok.poll_freq), + GNUNET_JSON_spec_array_const ("grants", + &jgrants), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (gfr->hr.reply, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + gfr->hr.http_status = 0; + gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + gpfh->cb (gpfh->cb_cls, + gfr); + return; + } + if (json_array_size (jgrants) > TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS) + { + gfr->hr.http_status = 0; + gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + gpfh->cb (gpfh->cb_cls, + gfr); + return; + } + { + unsigned int len = (unsigned int) json_array_size (jgrants); + struct TALER_MERCHANT_FountainGrant *grants + = GNUNET_new_array (GNUNET_NZL (len), + struct TALER_MERCHANT_FountainGrant); + size_t idx; + json_t *jg; + + json_array_foreach ((json_t *) jgrants, idx, jg) + { + struct TALER_MERCHANT_FountainGrant *fg = &grants[idx]; + uint32_t window; + struct GNUNET_JSON_Specification ispec[] = { + GNUNET_JSON_spec_string ("token_family_slug", + &fg->token_family_slug), + GNUNET_JSON_spec_uint64 ("tokens_per_period_limit", + &fg->tokens_per_period_limit), + GNUNET_JSON_spec_uint64 ("tokens_per_period_stash", + &fg->tokens_per_period_stash), + GNUNET_JSON_spec_uint32 ("key_window_size", + &window), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (jg, + ispec, + NULL, + NULL)) + { + GNUNET_break_op (0); + gfr->hr.http_status = 0; + gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + GNUNET_free (grants); + gpfh->cb (gpfh->cb_cls, + gfr); + return; + } + fg->key_window_size = window; + } + gfr->details.ok.grants = grants; + gfr->details.ok.grants_len = len; + gpfh->cb (gpfh->cb_cls, + gfr); + GNUNET_free (grants); + } +} + + +/** + * Function called when we're done processing the + * HTTP GET /private/fountains/$FOUNTAIN_ID request. + * + * @param cls the `struct TALER_MERCHANT_GetPrivateFountainHandle` + * @param response_code HTTP response code, 0 on error + * @param response response body, NULL if not in JSON + */ +static void +handle_get_fountain_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh = cls; + const json_t *json = response; + struct TALER_MERCHANT_GetPrivateFountainResponse gfr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + gpfh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "GET /private/fountains/$ID completed with response code %u\n", + (unsigned int) response_code); + switch (response_code) + { + case 0: + gfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + case MHD_HTTP_OK: + handle_ok (gpfh, + &gfr); + TALER_MERCHANT_get_private_fountain_cancel (gpfh); + return; + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + gfr.hr.ec = TALER_JSON_get_error_code (json); + gfr.hr.hint = TALER_JSON_get_error_hint (json); + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &gfr.hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u/%d\n", + (unsigned int) response_code, + (int) gfr.hr.ec); + GNUNET_break_op (0); + break; + } + gpfh->cb (gpfh->cb_cls, + &gfr); + TALER_MERCHANT_get_private_fountain_cancel (gpfh); +} + + +struct TALER_MERCHANT_GetPrivateFountainHandle * +TALER_MERCHANT_get_private_fountain_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_id) +{ + struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh; + + gpfh = GNUNET_new (struct TALER_MERCHANT_GetPrivateFountainHandle); + gpfh->ctx = ctx; + gpfh->base_url = GNUNET_strdup (url); + gpfh->fountain_id = GNUNET_strdup (fountain_id); + return gpfh; +} + + +enum TALER_ErrorCode +TALER_MERCHANT_get_private_fountain_start ( + struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh, + TALER_MERCHANT_GetPrivateFountainCallback cb, + TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls) +{ + CURL *eh; + + gpfh->cb = cb; + gpfh->cb_cls = cb_cls; + { + char *path; + + GNUNET_asprintf (&path, + "private/fountains/%s", + gpfh->fountain_id); + gpfh->url = TALER_url_join (gpfh->base_url, + path, + NULL); + GNUNET_free (path); + } + if (NULL == gpfh->url) + return TALER_EC_GENERIC_CONFIGURATION_INVALID; + eh = TALER_MERCHANT_curl_easy_get_ (gpfh->url); + if (NULL == eh) + { + GNUNET_break (0); + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + gpfh->job = GNUNET_CURL_job_add (gpfh->ctx, + eh, + &handle_get_fountain_finished, + gpfh); + if (NULL == gpfh->job) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + return TALER_EC_NONE; +} + + +void +TALER_MERCHANT_get_private_fountain_cancel ( + struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh) +{ + if (NULL != gpfh->job) + { + GNUNET_CURL_job_cancel (gpfh->job); + gpfh->job = NULL; + } + GNUNET_free (gpfh->fountain_id); + GNUNET_free (gpfh->url); + GNUNET_free (gpfh->base_url); + GNUNET_free (gpfh); +} + + +/* end of merchant_api_get-private-fountains-FOUNTAIN_ID.c */ diff --git a/src/lib/merchant_api_get-private-fountains.c b/src/lib/merchant_api_get-private-fountains.c @@ -0,0 +1,277 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as + published by the Free Software Foundation; either version 2.1, + or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with TALER; see the file COPYING.LGPL. + If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/lib/merchant_api_get-private-fountains.c + * @brief Implementation of the GET /private/fountains request + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> /* just for HTTP status codes */ +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <taler/merchant/get-private-fountains.h> +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> + + +/** + * Handle for a GET /private/fountains operation. + */ +struct TALER_MERCHANT_GetPrivateFountainsHandle +{ + /** + * Base URL of the merchant backend. + */ + char *base_url; + + /** + * The full URL for this request. + */ + char *url; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Function to call with the result. + */ + TALER_MERCHANT_GetPrivateFountainsCallback cb; + + /** + * Closure for @a cb. + */ + TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; +}; + + +/** + * Parse the "fountains" array and invoke the callback. + * + * @param gpfh operation handle + * @param[in,out] gfr response to complete and pass to the callback + */ +static void +handle_ok (struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh, + struct TALER_MERCHANT_GetPrivateFountainsResponse *gfr) +{ + const json_t *jfountains; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_array_const ("fountains", + &jfountains), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (gfr->hr.reply, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + gfr->hr.http_status = 0; + gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + gpfh->cb (gpfh->cb_cls, + gfr); + return; + } + /* This endpoint is not paginated, so bound allocation bytes rather than + imposing the much smaller limit used for grants within one fountain. + Check before narrowing the count or multiplying it by the entry size. */ + if (json_array_size (jfountains) >= + GNUNET_MAX_MALLOC_CHECKED / sizeof (struct TALER_MERCHANT_FountainEntry)) + { + gfr->hr.http_status = 0; + gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + gpfh->cb (gpfh->cb_cls, + gfr); + return; + } + { + unsigned int len = (unsigned int) json_array_size (jfountains); + struct TALER_MERCHANT_FountainEntry *fountains + = GNUNET_new_array (GNUNET_NZL (len), + struct TALER_MERCHANT_FountainEntry); + size_t idx; + json_t *jf; + + json_array_foreach ((json_t *) jfountains, idx, jf) + { + struct TALER_MERCHANT_FountainEntry *fe = &fountains[idx]; + struct GNUNET_JSON_Specification ispec[] = { + GNUNET_JSON_spec_string ("fountain_id", + &fe->fountain_id), + GNUNET_JSON_spec_string ("description", + &fe->description), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (jf, + ispec, + NULL, + NULL)) + { + GNUNET_break_op (0); + gfr->hr.http_status = 0; + gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + GNUNET_free (fountains); + gpfh->cb (gpfh->cb_cls, + gfr); + return; + } + } + gfr->details.ok.fountains = fountains; + gfr->details.ok.fountains_len = len; + gpfh->cb (gpfh->cb_cls, + gfr); + GNUNET_free (fountains); + } +} + + +/** + * Function called when we're done processing the + * HTTP GET /private/fountains request. + * + * @param cls the `struct TALER_MERCHANT_GetPrivateFountainsHandle` + * @param response_code HTTP response code, 0 on error + * @param response response body, NULL if not in JSON + */ +static void +handle_get_fountains_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh = cls; + const json_t *json = response; + struct TALER_MERCHANT_GetPrivateFountainsResponse gfr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + gpfh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "GET /private/fountains completed with response code %u\n", + (unsigned int) response_code); + switch (response_code) + { + case 0: + gfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + case MHD_HTTP_OK: + handle_ok (gpfh, + &gfr); + TALER_MERCHANT_get_private_fountains_cancel (gpfh); + return; + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + gfr.hr.ec = TALER_JSON_get_error_code (json); + gfr.hr.hint = TALER_JSON_get_error_hint (json); + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &gfr.hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u/%d\n", + (unsigned int) response_code, + (int) gfr.hr.ec); + GNUNET_break_op (0); + break; + } + gpfh->cb (gpfh->cb_cls, + &gfr); + TALER_MERCHANT_get_private_fountains_cancel (gpfh); +} + + +struct TALER_MERCHANT_GetPrivateFountainsHandle * +TALER_MERCHANT_get_private_fountains_create ( + struct GNUNET_CURL_Context *ctx, + const char *url) +{ + struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh; + + gpfh = GNUNET_new (struct TALER_MERCHANT_GetPrivateFountainsHandle); + gpfh->ctx = ctx; + gpfh->base_url = GNUNET_strdup (url); + return gpfh; +} + + +enum TALER_ErrorCode +TALER_MERCHANT_get_private_fountains_start ( + struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh, + TALER_MERCHANT_GetPrivateFountainsCallback cb, + TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls) +{ + CURL *eh; + + gpfh->cb = cb; + gpfh->cb_cls = cb_cls; + gpfh->url = TALER_url_join (gpfh->base_url, + "private/fountains", + NULL); + if (NULL == gpfh->url) + return TALER_EC_GENERIC_CONFIGURATION_INVALID; + eh = TALER_MERCHANT_curl_easy_get_ (gpfh->url); + if (NULL == eh) + { + GNUNET_break (0); + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + gpfh->job = GNUNET_CURL_job_add (gpfh->ctx, + eh, + &handle_get_fountains_finished, + gpfh); + if (NULL == gpfh->job) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + return TALER_EC_NONE; +} + + +void +TALER_MERCHANT_get_private_fountains_cancel ( + struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh) +{ + if (NULL != gpfh->job) + { + GNUNET_CURL_job_cancel (gpfh->job); + gpfh->job = NULL; + } + GNUNET_free (gpfh->url); + GNUNET_free (gpfh->base_url); + GNUNET_free (gpfh); +} + + +/* end of merchant_api_get-private-fountains.c */ diff --git a/src/lib/merchant_api_patch-private-fountains-FOUNTAIN_ID.c b/src/lib/merchant_api_patch-private-fountains-FOUNTAIN_ID.c @@ -0,0 +1,303 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as + published by the Free Software Foundation; either version 2.1, + or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with TALER; see the file COPYING.LGPL. + If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/lib/merchant_api_patch-private-fountains-FOUNTAIN_ID.c + * @brief Implementation of the PATCH /private/fountains/$FOUNTAIN_ID request + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> /* just for HTTP status codes */ +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <taler/merchant/patch-private-fountains-FOUNTAIN_ID.h> +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> + + +/** + * Handle for a PATCH /private/fountains/$FOUNTAIN_ID operation. + */ +struct TALER_MERCHANT_PatchPrivateFountainHandle +{ + /** + * Base URL of the merchant backend. + */ + char *base_url; + + /** + * The full URL for this request. + */ + char *url; + + /** + * Fountain identifier. + */ + char *fountain_id; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Function to call with the result. + */ + TALER_MERCHANT_PatchPrivateFountainCallback cb; + + /** + * Closure for @a cb. + */ + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; + + /** + * Minor context that holds body and headers. + */ + struct TALER_CURL_PostContext post_ctx; + + /** + * Request body being assembled from the options. + */ + json_t *req_obj; +}; + + +/** + * Function called when we're done processing the + * HTTP PATCH /private/fountains/$FOUNTAIN_ID request. + * + * @param cls the `struct TALER_MERCHANT_PatchPrivateFountainHandle` + * @param response_code HTTP response code, 0 on error + * @param response response body, NULL if not in JSON + */ +static void +handle_patch_fountain_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh = cls; + const json_t *json = response; + struct TALER_MERCHANT_PatchPrivateFountainResponse pfr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + ppfh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "PATCH /private/fountains/$ID completed with response code %u\n", + (unsigned int) response_code); + switch (response_code) + { + case 0: + pfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + case MHD_HTTP_NO_CONTENT: + break; + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + pfr.hr.ec = TALER_JSON_get_error_code (json); + pfr.hr.hint = TALER_JSON_get_error_hint (json); + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &pfr.hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u/%d\n", + (unsigned int) response_code, + (int) pfr.hr.ec); + GNUNET_break_op (0); + break; + } + ppfh->cb (ppfh->cb_cls, + &pfr); + TALER_MERCHANT_patch_private_fountain_cancel (ppfh); +} + + +struct TALER_MERCHANT_PatchPrivateFountainHandle * +TALER_MERCHANT_patch_private_fountain_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_id) +{ + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh; + + ppfh = GNUNET_new (struct TALER_MERCHANT_PatchPrivateFountainHandle); + ppfh->ctx = ctx; + ppfh->base_url = GNUNET_strdup (url); + ppfh->fountain_id = GNUNET_strdup (fountain_id); + ppfh->req_obj = json_object (); + GNUNET_assert (NULL != ppfh->req_obj); + return ppfh; +} + + +enum GNUNET_GenericReturnValue +TALER_MERCHANT_patch_private_fountain_set_options_ ( + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh, + unsigned int num_options, + const struct TALER_MERCHANT_PatchPrivateFountainOptionValue *options) +{ + for (unsigned int i = 0; i < num_options; i++) + { + switch (options[i].option) + { + case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_END: + return GNUNET_OK; + case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION: + GNUNET_assert (0 == + json_object_set_new ( + ppfh->req_obj, + "description", + json_string (options[i].details.description))); + break; + case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ: + GNUNET_assert (0 == + json_object_set_new ( + ppfh->req_obj, + "poll_freq", + GNUNET_JSON_from_time_rel ( + options[i].details.poll_freq))); + break; + case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS: + { + json_t *jgrants; + + jgrants = json_array (); + GNUNET_assert (NULL != jgrants); + for (unsigned int j = 0; + j < options[i].details.grants.num_grants; + j++) + { + const struct TALER_MERCHANT_FountainGrant *fg + = &options[i].details.grants.grants[j]; + + GNUNET_assert (0 == + json_array_append_new ( + jgrants, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ( + "token_family_slug", + fg->token_family_slug), + GNUNET_JSON_pack_uint64 ( + "tokens_per_period_limit", + fg->tokens_per_period_limit), + GNUNET_JSON_pack_uint64 ( + "tokens_per_period_stash", + fg->tokens_per_period_stash), + GNUNET_JSON_pack_uint64 ( + "key_window_size", + fg->key_window_size)))); + } + GNUNET_assert (0 == + json_object_set_new (ppfh->req_obj, + "grants", + jgrants)); + break; + } + default: + GNUNET_break (0); + return GNUNET_SYSERR; + } + } + return GNUNET_OK; +} + + +enum TALER_ErrorCode +TALER_MERCHANT_patch_private_fountain_start ( + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh, + TALER_MERCHANT_PatchPrivateFountainCallback cb, + TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls) +{ + CURL *eh; + + ppfh->cb = cb; + ppfh->cb_cls = cb_cls; + { + char *path; + + GNUNET_asprintf (&path, + "private/fountains/%s", + ppfh->fountain_id); + ppfh->url = TALER_url_join (ppfh->base_url, + path, + NULL); + GNUNET_free (path); + } + if (NULL == ppfh->url) + return TALER_EC_GENERIC_CONFIGURATION_INVALID; + eh = TALER_MERCHANT_curl_easy_get_ (ppfh->url); + if ( (NULL == eh) || + (GNUNET_OK != + TALER_curl_easy_post (&ppfh->post_ctx, + eh, + ppfh->req_obj)) ) + { + GNUNET_break (0); + if (NULL != eh) + curl_easy_cleanup (eh); + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, + CURLOPT_CUSTOMREQUEST, + MHD_HTTP_METHOD_PATCH)); + ppfh->job = GNUNET_CURL_job_add2 (ppfh->ctx, + eh, + ppfh->post_ctx.headers, + &handle_patch_fountain_finished, + ppfh); + if (NULL == ppfh->job) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + return TALER_EC_NONE; +} + + +void +TALER_MERCHANT_patch_private_fountain_cancel ( + struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh) +{ + if (NULL != ppfh->job) + { + GNUNET_CURL_job_cancel (ppfh->job); + ppfh->job = NULL; + } + TALER_curl_easy_post_finished (&ppfh->post_ctx); + json_decref (ppfh->req_obj); + GNUNET_free (ppfh->fountain_id); + GNUNET_free (ppfh->url); + GNUNET_free (ppfh->base_url); + GNUNET_free (ppfh); +} + + +/* end of merchant_api_patch-private-fountains-FOUNTAIN_ID.c */ diff --git a/src/lib/merchant_api_post-fountain-withdraw.c b/src/lib/merchant_api_post-fountain-withdraw.c @@ -0,0 +1,402 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as + published by the Free Software Foundation; either version 2.1, + or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with TALER; see the file COPYING.LGPL. + If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/lib/merchant_api_post-fountain-withdraw.c + * @brief Implementation of the (public) POST /fountain/withdraw request + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> /* just for HTTP status codes */ +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <taler/merchant/post-fountain-withdraw.h> +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> + + +/** + * Handle for a POST /fountain/withdraw operation. + */ +struct TALER_MERCHANT_PostFountainWithdrawHandle +{ + /** + * Base URL of the merchant backend. + */ + char *base_url; + + /** + * The full URL for this request. + */ + char *url; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Function to call with the result. + */ + TALER_MERCHANT_PostFountainWithdrawCallback cb; + + /** + * Closure for @a cb. + */ + TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cb_cls; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; + + /** + * Minor context that holds body and headers. + */ + struct TALER_CURL_PostContext post_ctx; + + /** + * Request body. + */ + json_t *req_obj; +}; + + +/** + * Parse the withdraw results and invoke the callback. + * + * @param pfwh operation handle + * @param[in,out] fwr response to complete and pass to the callback + */ +static void +handle_ok (struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh, + struct TALER_MERCHANT_PostFountainWithdrawResponse *fwr) +{ + const json_t *jresults; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_array_const ("grants", + &jresults), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (fwr->hr.reply, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + fwr->hr.http_status = 0; + fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + pfwh->cb (pfwh->cb_cls, + fwr); + return; + } + if (json_array_size (jresults) > TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS) + { + fwr->hr.http_status = 0; + fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + pfwh->cb (pfwh->cb_cls, + fwr); + return; + } + { + unsigned int len = (unsigned int) json_array_size (jresults); + struct TALER_MERCHANT_FountainWithdrawResult *results; + struct TALER_BlindedTokenIssueSignature *all_sigs = NULL; + unsigned int num_all_sigs = 0; + bool fail = false; + size_t idx; + json_t *jr; + + /* Bound the total before adding, narrowing or allocating. */ + json_array_foreach ((json_t *) jresults, idx, jr) + { + const json_t *jsigs = json_object_get (jr, + "token_sigs"); + + if ( (! json_is_array (jsigs)) || + (json_array_size (jsigs) > + TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS - num_all_sigs) ) + { + fwr->hr.http_status = 0; + fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + pfwh->cb (pfwh->cb_cls, + fwr); + return; + } + num_all_sigs += (unsigned int) json_array_size (jsigs); + } + results = GNUNET_new_array (GNUNET_NZL (len), + struct TALER_MERCHANT_FountainWithdrawResult); + all_sigs = GNUNET_new_array (GNUNET_NZL (num_all_sigs), + struct TALER_BlindedTokenIssueSignature); + num_all_sigs = 0; + json_array_foreach ((json_t *) jresults, idx, jr) + { + struct TALER_MERCHANT_FountainWithdrawResult *res = &results[idx]; + const json_t *jsigs; + struct GNUNET_JSON_Specification ispec[] = { + GNUNET_JSON_spec_string ("token_family_slug", + &res->token_family_slug), + GNUNET_JSON_spec_fixed_auto ("h_issue", + &res->h_issue), + GNUNET_JSON_spec_array_const ("token_sigs", + &jsigs), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (jr, + ispec, + NULL, + NULL)) + { + GNUNET_break_op (0); + fail = true; + break; + } + res->token_sigs = &all_sigs[num_all_sigs]; + res->num_sigs = (unsigned int) json_array_size (jsigs); + { + size_t sidx; + json_t *js; + + json_array_foreach ((json_t *) jsigs, sidx, js) + { + struct GNUNET_JSON_Specification sspec[] = { + GNUNET_JSON_spec_blinded_signature ( + "blind_sig", + &all_sigs[num_all_sigs].signature), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (js, + sspec, + NULL, + NULL)) + { + GNUNET_break_op (0); + fail = true; + break; + } + num_all_sigs++; + } + } + if (fail) + break; + } + if (fail) + { + fwr->hr.http_status = 0; + fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + pfwh->cb (pfwh->cb_cls, + fwr); + } + else + { + fwr->details.ok.results = results; + fwr->details.ok.results_len = len; + pfwh->cb (pfwh->cb_cls, + fwr); + } + for (unsigned int i = 0; i < num_all_sigs; i++) + if (NULL != all_sigs[i].signature) + GNUNET_CRYPTO_blinded_sig_decref (all_sigs[i].signature); + GNUNET_free (all_sigs); + GNUNET_free (results); + } +} + + +/** + * Function called when we're done processing the + * HTTP POST /fountain/withdraw request. + * + * @param cls the `struct TALER_MERCHANT_PostFountainWithdrawHandle` + * @param response_code HTTP response code, 0 on error + * @param response response body, NULL if not in JSON + */ +static void +handle_post_fountain_withdraw_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh = cls; + const json_t *json = response; + struct TALER_MERCHANT_PostFountainWithdrawResponse fwr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + pfwh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "POST /fountain/withdraw completed with response code %u\n", + (unsigned int) response_code); + switch (response_code) + { + case 0: + fwr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + case MHD_HTTP_OK: + handle_ok (pfwh, + &fwr); + TALER_MERCHANT_post_fountain_withdraw_cancel (pfwh); + return; + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_CONFLICT: + case MHD_HTTP_TOO_MANY_REQUESTS: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + fwr.hr.ec = TALER_JSON_get_error_code (json); + fwr.hr.hint = TALER_JSON_get_error_hint (json); + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &fwr.hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u/%d\n", + (unsigned int) response_code, + (int) fwr.hr.ec); + GNUNET_break_op (0); + break; + } + pfwh->cb (pfwh->cb_cls, + &fwr); + TALER_MERCHANT_post_fountain_withdraw_cancel (pfwh); +} + + +struct TALER_MERCHANT_PostFountainWithdrawHandle * +TALER_MERCHANT_post_fountain_withdraw_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *fountain_secret, + unsigned int num_entries, + const struct TALER_MERCHANT_FountainWithdrawEntry *entries) +{ + struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh; + json_t *jentries; + + jentries = json_array (); + GNUNET_assert (NULL != jentries); + for (unsigned int i = 0; i < num_entries; i++) + { + const struct TALER_MERCHANT_FountainWithdrawEntry *we = &entries[i]; + json_t *jenvelopes; + + jenvelopes = json_array (); + GNUNET_assert (NULL != jenvelopes); + for (unsigned int j = 0; j < we->num_envelopes; j++) + GNUNET_assert (0 == + json_array_append_new ( + jenvelopes, + GNUNET_JSON_PACK ( + TALER_JSON_pack_token_envelope (NULL, + &we->envelopes[j])))); + GNUNET_assert (0 == + json_array_append_new ( + jentries, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("token_family_slug", + we->token_family_slug), + GNUNET_JSON_pack_allow_null ( + GNUNET_TIME_absolute_is_zero ( + we->valid_at.abs_time) + ? GNUNET_JSON_pack_string ("valid_at", + NULL) + : GNUNET_JSON_pack_timestamp ("valid_at", + we->valid_at)), + GNUNET_JSON_pack_array_steal ("envelopes", + jenvelopes)))); + } + pfwh = GNUNET_new (struct TALER_MERCHANT_PostFountainWithdrawHandle); + pfwh->ctx = ctx; + pfwh->base_url = GNUNET_strdup (url); + pfwh->req_obj = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("fountain_secret", + fountain_secret), + GNUNET_JSON_pack_array_steal ("grants", + jentries)); + return pfwh; +} + + +enum TALER_ErrorCode +TALER_MERCHANT_post_fountain_withdraw_start ( + struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh, + TALER_MERCHANT_PostFountainWithdrawCallback cb, + TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cb_cls) +{ + CURL *eh; + + pfwh->cb = cb; + pfwh->cb_cls = cb_cls; + pfwh->url = TALER_url_join (pfwh->base_url, + "fountain/withdraw", + NULL); + if (NULL == pfwh->url) + return TALER_EC_GENERIC_CONFIGURATION_INVALID; + eh = TALER_MERCHANT_curl_easy_get_ (pfwh->url); + if ( (NULL == eh) || + (GNUNET_OK != + TALER_curl_easy_post (&pfwh->post_ctx, + eh, + pfwh->req_obj)) ) + { + GNUNET_break (0); + if (NULL != eh) + curl_easy_cleanup (eh); + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + pfwh->job = GNUNET_CURL_job_add2 (pfwh->ctx, + eh, + pfwh->post_ctx.headers, + &handle_post_fountain_withdraw_finished, + pfwh); + if (NULL == pfwh->job) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + return TALER_EC_NONE; +} + + +void +TALER_MERCHANT_post_fountain_withdraw_cancel ( + struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh) +{ + if (NULL != pfwh->job) + { + GNUNET_CURL_job_cancel (pfwh->job); + pfwh->job = NULL; + } + TALER_curl_easy_post_finished (&pfwh->post_ctx); + json_decref (pfwh->req_obj); + GNUNET_free (pfwh->url); + GNUNET_free (pfwh->base_url); + GNUNET_free (pfwh); +} + + +/* end of merchant_api_post-fountain-withdraw.c */ diff --git a/src/lib/merchant_api_post-private-fountains.c b/src/lib/merchant_api_post-private-fountains.c @@ -0,0 +1,260 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as + published by the Free Software Foundation; either version 2.1, + or (at your option) any later version. + + 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with TALER; see the file COPYING.LGPL. + If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file src/lib/merchant_api_post-private-fountains.c + * @brief Implementation of the POST /private/fountains request + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> /* just for HTTP status codes */ +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <taler/merchant/post-private-fountains.h> +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> + + +/** + * Handle for a POST /private/fountains operation. + */ +struct TALER_MERCHANT_PostPrivateFountainsHandle +{ + /** + * Base URL of the merchant backend. + */ + char *base_url; + + /** + * The full URL for this request. + */ + char *url; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Function to call with the result. + */ + TALER_MERCHANT_PostPrivateFountainsCallback cb; + + /** + * Closure for @a cb. + */ + TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; + + /** + * Minor context that holds body and headers. + */ + struct TALER_CURL_PostContext post_ctx; + + /** + * Request body. + */ + json_t *req_obj; +}; + + +/** + * Function called when we're done processing the + * HTTP POST /private/fountains request. + * + * @param cls the `struct TALER_MERCHANT_PostPrivateFountainsHandle` + * @param response_code HTTP response code, 0 on error + * @param response response body, NULL if not in JSON + */ +static void +handle_post_fountains_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh = cls; + const json_t *json = response; + struct TALER_MERCHANT_PostPrivateFountainsResponse pfr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + ppfh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "POST /private/fountains completed with response code %u\n", + (unsigned int) response_code); + switch (response_code) + { + case 0: + pfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + case MHD_HTTP_OK: + { + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("fountain_id", + &pfr.details.ok.fountain_id), + GNUNET_JSON_spec_string ("fountain_secret", + &pfr.details.ok.fountain_secret), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (json, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + pfr.hr.http_status = 0; + pfr.hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + } + break; + } + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + pfr.hr.ec = TALER_JSON_get_error_code (json); + pfr.hr.hint = TALER_JSON_get_error_hint (json); + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &pfr.hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u/%d\n", + (unsigned int) response_code, + (int) pfr.hr.ec); + GNUNET_break_op (0); + break; + } + ppfh->cb (ppfh->cb_cls, + &pfr); + TALER_MERCHANT_post_private_fountains_cancel (ppfh); +} + + +struct TALER_MERCHANT_PostPrivateFountainsHandle * +TALER_MERCHANT_post_private_fountains_create ( + struct GNUNET_CURL_Context *ctx, + const char *url, + const char *description, + struct GNUNET_TIME_Relative poll_freq, + unsigned int num_grants, + const struct TALER_MERCHANT_FountainGrant *grants) +{ + struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh; + json_t *jgrants; + + jgrants = json_array (); + GNUNET_assert (NULL != jgrants); + for (unsigned int i = 0; i < num_grants; i++) + { + GNUNET_assert (0 == + json_array_append_new ( + jgrants, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("token_family_slug", + grants[i].token_family_slug), + GNUNET_JSON_pack_uint64 ("tokens_per_period_limit", + grants[i]. + tokens_per_period_limit), + GNUNET_JSON_pack_uint64 ("tokens_per_period_stash", + grants[i]. + tokens_per_period_stash), + GNUNET_JSON_pack_uint64 ("key_window_size", + grants[i].key_window_size)))); + } + ppfh = GNUNET_new (struct TALER_MERCHANT_PostPrivateFountainsHandle); + ppfh->ctx = ctx; + ppfh->base_url = GNUNET_strdup (url); + ppfh->req_obj = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("description", + description), + GNUNET_JSON_pack_time_rel ("poll_freq", + poll_freq), + GNUNET_JSON_pack_array_steal ("grants", + jgrants)); + return ppfh; +} + + +enum TALER_ErrorCode +TALER_MERCHANT_post_private_fountains_start ( + struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh, + TALER_MERCHANT_PostPrivateFountainsCallback cb, + TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls) +{ + CURL *eh; + + ppfh->cb = cb; + ppfh->cb_cls = cb_cls; + ppfh->url = TALER_url_join (ppfh->base_url, + "private/fountains", + NULL); + if (NULL == ppfh->url) + return TALER_EC_GENERIC_CONFIGURATION_INVALID; + eh = TALER_MERCHANT_curl_easy_get_ (ppfh->url); + if ( (NULL == eh) || + (GNUNET_OK != + TALER_curl_easy_post (&ppfh->post_ctx, + eh, + ppfh->req_obj)) ) + { + GNUNET_break (0); + if (NULL != eh) + curl_easy_cleanup (eh); + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + ppfh->job = GNUNET_CURL_job_add2 (ppfh->ctx, + eh, + ppfh->post_ctx.headers, + &handle_post_fountains_finished, + ppfh); + if (NULL == ppfh->job) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + return TALER_EC_NONE; +} + + +void +TALER_MERCHANT_post_private_fountains_cancel ( + struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh) +{ + if (NULL != ppfh->job) + { + GNUNET_CURL_job_cancel (ppfh->job); + ppfh->job = NULL; + } + TALER_curl_easy_post_finished (&ppfh->post_ctx); + json_decref (ppfh->req_obj); + GNUNET_free (ppfh->url); + GNUNET_free (ppfh->base_url); + GNUNET_free (ppfh); +} + + +/* end of merchant_api_post-private-fountains.c */ diff --git a/src/lib/meson.build b/src/lib/meson.build @@ -6,6 +6,7 @@ libtalermerchant_la_SOURCES = [ 'merchant_api_common_mfa_challenge.c', 'merchant_api_delete-management-instances-INSTANCE.c', 'merchant_api_delete-private-accounts-H_WIRE.c', + 'merchant_api_delete-private-fountains-FOUNTAIN_ID.c', 'merchant_api_delete-private-orders-ORDER_ID.c', 'merchant_api_delete-private-otp-devices-DEVICE_ID.c', 'merchant_api_delete-private-products-PRODUCT_ID.c', @@ -16,11 +17,14 @@ libtalermerchant_la_SOURCES = [ 'merchant_api_delete-private-units-UNIT.c', 'merchant_api_delete-private-webhooks-WEBHOOK_ID.c', 'merchant_api_get-config.c', + 'merchant_api_get-fountain-info.c', 'merchant_api_get-management-instances.c', 'merchant_api_get-management-instances-INSTANCE.c', 'merchant_api_get-orders-ORDER_ID.c', 'merchant_api_get-private-accounts.c', 'merchant_api_get-private-accounts-H_WIRE.c', + 'merchant_api_get-private-fountains.c', + 'merchant_api_get-private-fountains-FOUNTAIN_ID.c', 'merchant_api_get-private-kyc.c', 'merchant_api_get-private-orders.c', 'merchant_api_get-private-orders-ORDER_ID.c', @@ -42,12 +46,14 @@ libtalermerchant_la_SOURCES = [ 'merchant_api_get-templates-TEMPLATE_ID.c', 'merchant_api_patch-management-instances-INSTANCE.c', 'merchant_api_patch-private-accounts-H_WIRE.c', + 'merchant_api_patch-private-fountains-FOUNTAIN_ID.c', 'merchant_api_patch-private-orders-ORDER_ID-forget.c', 'merchant_api_patch-private-otp-devices-DEVICE_ID.c', 'merchant_api_patch-private-products-PRODUCT_ID.c', 'merchant_api_patch-private-templates-TEMPLATE_ID.c', 'merchant_api_patch-private-units-UNIT.c', 'merchant_api_patch-private-webhooks-WEBHOOK_ID.c', + 'merchant_api_post-fountain-withdraw.c', 'merchant_api_post-management-instances.c', 'merchant_api_post-management-instances-INSTANCE-auth.c', 'merchant_api_post-orders-ORDER_ID-abort.c', @@ -57,6 +63,7 @@ libtalermerchant_la_SOURCES = [ 'merchant_api_post-orders-ORDER_ID-refund.c', 'merchant_api_post-private-accounts.c', 'merchant_api_post-private-categories.c', + 'merchant_api_post-private-fountains.c', 'merchant_api_post-private-orders.c', 'merchant_api_post-private-orders-ORDER_ID-refund.c', 'merchant_api_post-private-orders-ORDER_ID-refund-external.c', @@ -85,6 +92,7 @@ libtalermerchant = library( dependencies: [ donau_dep, donaujson_dep, + libtalermerchantutil_dep, talerexchange_dep, talercurl_dep, talerkyclogic_dep, @@ -109,6 +117,20 @@ pkg.generate( ) +test_fountain_parsers = executable( + 'test_fountain_parsers', + 'test_fountain_parsers.c', + dependencies: [ + libtalermerchant_dep, libtalermerchantutil_dep, + talerjson_dep, talerutil_dep, talercurl_dep, + gnunetjson_dep, gnunetutil_dep, gnunetcurl_dep, json_dep, curl_dep, + ], + include_directories: [incdir, configuration_inc], + install: false, +) +test('test_fountain_parsers', test_fountain_parsers, suite: ['merchant']) + + talermerchant_tests = ['test_merchant_api_common'] talermerchant_tests_installcheck = [] diff --git a/src/lib/test_fountain_parsers.c b/src/lib/test_fountain_parsers.c @@ -0,0 +1,328 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free + Software Foundation; either version 2.1, or (at your option) any later version. + + 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 Lesser General Public License for + more details. You should have received a copy along with TALER; see + COPYING.LGPL. If not, see <http://www.gnu.org/licenses/>. +*/ +/** + * @file lib/test_fountain_parsers.c + * @brief Exercise the HTTP-200 parsers directly, including hostile array sizes. + */ +#define handle_ok info_handle_ok +#include "merchant_api_get-fountain-info.c" +#undef handle_ok +#define handle_ok withdraw_handle_ok +#include "merchant_api_post-fountain-withdraw.c" +#undef handle_ok +#define handle_ok list_handle_ok +#include "merchant_api_get-private-fountains.c" +#undef handle_ok +#define handle_ok detail_handle_ok +#include "merchant_api_get-private-fountains-FOUNTAIN_ID.c" +#undef handle_ok +#include <sys/resource.h> + +struct Expected +{ + unsigned int count; + unsigned int calls; + bool malformed; +}; + +static void +info_cb (void *cls, + const struct TALER_MERCHANT_GetFountainInfoResponse *r) +{ + struct Expected *e = cls; + + e->calls++; + GNUNET_assert (e->malformed ? (0 == r->hr.http_status && + TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec) + : (MHD_HTTP_OK == r->hr.http_status && + e->count == r->details.ok.grants_len)); + if ( (! e->malformed) && (0 != e->count) ) + GNUNET_assert (0 == strcmp (r->details.ok.grants[0].token_family.name, + "Test")); +} + + +static void +withdraw_cb (void *cls, + const struct TALER_MERCHANT_PostFountainWithdrawResponse *r) +{ + struct Expected *e = cls; + + e->calls++; + GNUNET_assert (e->malformed ? (0 == r->hr.http_status && + TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec) + : (MHD_HTTP_OK == r->hr.http_status && + e->count == r->details.ok.results_len)); + if ( (! e->malformed) && (0 != e->count) ) + GNUNET_assert (0 == strcmp (r->details.ok.results[0].token_family_slug, + "test")); +} + + +static void +list_cb (void *cls, + const struct TALER_MERCHANT_GetPrivateFountainsResponse *r) +{ + struct Expected *e = cls; + + e->calls++; + GNUNET_assert (e->malformed ? (0 == r->hr.http_status && + TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec) + : (MHD_HTTP_OK == r->hr.http_status && + e->count == r->details.ok.fountains_len)); + if ( (! e->malformed) && (0 != e->count) ) + GNUNET_assert (0 == strcmp (r->details.ok.fountains[0].fountain_id, + "test")); +} + + +static void +detail_cb (void *cls, + const struct TALER_MERCHANT_GetPrivateFountainResponse *r) +{ + struct Expected *e = cls; + + e->calls++; + GNUNET_assert (e->malformed ? (0 == r->hr.http_status && + TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec) + : (MHD_HTTP_OK == r->hr.http_status && + e->count == r->details.ok.grants_len)); + if ( (! e->malformed) && (0 != e->count) ) + GNUNET_assert (0 == strcmp (r->details.ok.grants[0].token_family_slug, + "test")); +} + + +static void +check (json_t *grants, + unsigned int mode, + bool malformed) +{ + struct Expected expected = { + .count = json_array_size (grants), + .malformed = malformed + }; + json_t *reply = json_pack ("{s:{s:i},s:O,s:O,s:s}", + "poll_freq", "d_us", 1000000, + "grants", grants, "fountains", grants, + "description", "Test"); + + if (1 == mode) + { + struct TALER_MERCHANT_PostFountainWithdrawHandle h = { + .cb = &withdraw_cb, .cb_cls = &expected + }; + struct TALER_MERCHANT_PostFountainWithdrawResponse r = { + .hr = {.http_status = MHD_HTTP_OK, .reply = reply} + }; + + withdraw_handle_ok (&h, &r); + } + else if (0 == mode) + { + struct TALER_MERCHANT_GetFountainInfoHandle h = { + .cb = &info_cb, .cb_cls = &expected + }; + struct TALER_MERCHANT_GetFountainInfoResponse r = { + .hr = {.http_status = MHD_HTTP_OK, .reply = reply} + }; + + info_handle_ok (&h, &r); + } + else if (2 == mode) + { + struct TALER_MERCHANT_GetPrivateFountainsHandle h = { + .cb = &list_cb, .cb_cls = &expected + }; + struct TALER_MERCHANT_GetPrivateFountainsResponse r = { + .hr = {.http_status = MHD_HTTP_OK, .reply = reply} + }; + + list_handle_ok (&h, &r); + } + else + { + struct TALER_MERCHANT_GetPrivateFountainHandle h = { + .cb = &detail_cb, .cb_cls = &expected + }; + struct TALER_MERCHANT_GetPrivateFountainResponse r = { + .hr = {.http_status = MHD_HTTP_OK, .reply = reply} + }; + + detail_handle_ok (&h, &r); + } + GNUNET_assert (1 == expected.calls); + json_decref (reply); +} + + +/** + * Exercise each nested array through the real fountain-info parser. Use a + * preceding valid grant as well, so every failure tests partial cleanup. + */ +static void +check_nested_arrays (json_t *info) +{ + const char *fields[] = { "keys", "expected_domains", "trusted_domains" }; + const unsigned int limits[] = { + TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS, + TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS, + TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS + }; + /* 10 MB of null keys, or 30 MB of null domains, fits the HTTP limit but + previously triggered a 48 MB allocation on a 64-bit client. */ + const unsigned int hostile_counts[] = { 2000000, 6000000, 6000000 }; + struct TALER_TokenIssuePrivateKey priv; + struct TALER_TokenIssuePublicKey pub; + struct GNUNET_TIME_Timestamp start = { + .abs_time.abs_value_us = 1000000 + }; + struct GNUNET_TIME_Timestamp end = { + .abs_time.abs_value_us = 2000000 + }; + json_t *key; + json_t *domain = json_string ("merchant.example"); + + GNUNET_CRYPTO_blind_sign_keys_create (&priv.private_key, + &pub.public_key, + GNUNET_CRYPTO_BSA_CS); + key = GNUNET_JSON_PACK ( + TALER_JSON_pack_token_pub (NULL, &pub), + GNUNET_JSON_pack_timestamp ("signature_validity_start", start), + GNUNET_JSON_pack_timestamp ("signature_validity_end", end)); + GNUNET_CRYPTO_blind_sign_priv_decref (priv.private_key); + GNUNET_CRYPTO_blind_sign_pub_decref (pub.public_key); + for (unsigned int mode = 0; mode < 3; mode++) + { + json_t *grant = json_deep_copy (info); + json_t *family = json_object_get (grant, "token_family"); + json_t *details = json_object_get (family, "details"); + json_t *array = json_array (); + json_t *grants = json_array (); + json_t *valid = (0 == mode) ? key : domain; + + if (2 == mode) + { + GNUNET_assert (0 == json_object_set_new ( + details, "class", json_string ("subscription"))); + GNUNET_assert (0 == json_object_del (details, "expected_domains")); + } + GNUNET_assert (0 == json_object_set ((0 == mode) ? family : details, + fields[mode], array)); + GNUNET_assert (0 == json_array_append (grants, info)); + GNUNET_assert (0 == json_array_append (grants, grant)); + check (grants, 0, false); + GNUNET_assert (0 == json_array_append (array, valid)); + check (grants, 0, false); + GNUNET_assert (0 == json_array_append_new (array, json_null ())); + check (grants, 0, true); + json_array_clear (array); + for (unsigned int i = 0; i < limits[mode]; i++) + GNUNET_assert (0 == json_array_append (array, valid)); + check (grants, 0, false); + GNUNET_assert (0 == json_array_append (array, valid)); + check (grants, 0, true); + json_array_clear (array); + for (unsigned int i = 0; i < hostile_counts[mode]; i++) + GNUNET_assert (0 == json_array_append_new (array, json_null ())); + check (grants, 0, true); + json_decref (grants); + json_decref (grant); + json_decref (array); + } + json_decref (key); + json_decref (domain); +} + + +int +main (void) +{ + struct rlimit limit; + json_t *grants = json_array (); + json_t *info = json_loads ( + "{\"token_family_slug\":\"test\",\"tokens_per_period_limit\":1," + "\"tokens_per_period_stash\":1,\"key_window_size\":0," + "\"token_family\":{\"name\":\"Test\",\"description\":\"Test\"," + "\"keys\":[],\"critical\":false," + "\"details\":{\"class\":\"discount\",\"expected_domains\":[]}}}", + 0, NULL); + struct TALER_TokenIssuePublicKeyHashP hash = {0}; + json_t *withdraw = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("token_family_slug", "test"), + GNUNET_JSON_pack_data_auto ("h_issue", &hash), + GNUNET_JSON_pack_array_steal ("token_sigs", json_array ())); + + json_t *list = json_pack ("{s:s,s:s}", + "fountain_id", "test", "description", "Test"); + json_t *valid_grants[] = { info, withdraw, list, info }; + unsigned int large_counts[] = { 100000, 100000, 600000, 300000 }; + + GNUNET_assert (0 == getrlimit (RLIMIT_STACK, &limit)); + limit.rlim_cur = GNUNET_MIN (limit.rlim_cur, 8 * 1024 * 1024); + GNUNET_assert (0 == setrlimit (RLIMIT_STACK, &limit)); + GNUNET_assert (NULL != info); + for (unsigned int mode = 0; mode < 4; mode++) + { + json_t *valid = valid_grants[mode]; + + json_array_clear (grants); + check (grants, mode, false); + GNUNET_assert (0 == json_array_append (grants, valid)); + check (grants, mode, false); + /* Failure after a parsed grant must release partially parsed data. */ + GNUNET_assert (0 == json_array_append_new ( + grants, + json_pack ("{s:[]}", "token_sigs"))); + check (grants, mode, true); + json_array_clear (grants); + for (unsigned int i = 0; + i < TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS; i++) + GNUNET_assert (0 == json_array_append (grants, valid)); + check (grants, mode, false); + GNUNET_assert (0 == json_array_append (grants, valid)); + check (grants, mode, 2 != mode); + json_array_clear (grants); + for (unsigned int i = 0; i < large_counts[mode]; i++) + GNUNET_assert (0 == json_array_append_new (grants, json_null ())); + check (grants, mode, true); + } + /* A list is not capped at the grant limit, but still has a byte budget. */ + json_array_clear (grants); + for (size_t i = 0; + i < GNUNET_MAX_MALLOC_CHECKED / sizeof (struct TALER_MERCHANT_FountainEntry); + i++) + GNUNET_assert (0 == json_array_append (grants, list)); + check (grants, 2, true); + /* A small grant array must not hide an excessive signature total. */ + json_array_clear (grants); + for (unsigned int i = 0; i < 2; i++) + { + json_t *grant = json_deep_copy (withdraw); + json_t *sigs = json_object_get (grant, "token_sigs"); + + for (unsigned int j = 0; + j <= TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS / 2; j++) + GNUNET_assert (0 == json_array_append_new (sigs, json_object ())); + GNUNET_assert (0 == json_array_append_new (grants, grant)); + } + check (grants, true, true); + json_decref (grants); + check_nested_arrays (info); + json_decref (info); + json_decref (withdraw); + json_decref (list); + return 0; +} diff --git a/src/testing/meson.build b/src/testing/meson.build @@ -2,6 +2,7 @@ check_SCRIPTS = [ + 'test_merchant_fountains', 'test_merchant_instance_auth', 'test_merchant_instance_creation', 'test_merchant_instance_response', @@ -91,6 +92,8 @@ libtalermerchanttesting_la_SOURCES = [ 'testing_api_cmd_post_transfers.c', 'testing_api_cmd_post_templates.c', 'testing_api_cmd_post_units.c', + 'testing_api_cmd_post_fountains.c', + 'testing_api_cmd_fountain_withdraw.c', 'testing_api_cmd_post_tokenfamilies.c', 'testing_api_cmd_post_using_templates.c', 'testing_api_cmd_post_webhooks.c', diff --git a/src/testing/test_merchant_api.c b/src/testing/test_merchant_api.c @@ -309,6 +309,35 @@ cmd_transfer_to_exchange (const char *label, /** + * Grants of the fountain used in the token tests (DD 98): the + * "subscription-1" family may be withdrawn from, at most 3 tokens + * per issue-key validity period, for the current and the next slot. + */ +static const struct TALER_MERCHANT_FountainGrant fountain_grants[] = { + { + .token_family_slug = "subscription-1", + .tokens_per_period_limit = 3, + .tokens_per_period_stash = 2, + .key_window_size = 1 + } +}; + + +/** + * Grants referring to a token family that the fountain does not + * grant; used to check that withdrawing from it is refused. + */ +static const struct TALER_MERCHANT_FountainGrant fountain_grants_other[] = { + { + .token_family_slug = "subscription-upcoming", + .tokens_per_period_limit = 1, + .tokens_per_period_stash = 1, + .key_window_size = 0 + } +}; + + +/** * Main function that will tell the interpreter what commands to * run. * @@ -3510,6 +3539,164 @@ run (void *cls, NULL, 0, "pay-order-with-output"), + /* Token fountains (DD 98) */ + TALER_TESTING_cmd_merchant_post_fountains ( + "create-fountain", + merchant_url, + MHD_HTTP_OK, + "campaign-reference-1", + GNUNET_TIME_UNIT_HOURS, + 1, + fountain_grants), + TALER_TESTING_cmd_merchant_post_fountains ( + "create-fountain-unknown-family", + merchant_url, + MHD_HTTP_NOT_FOUND, + "campaign-with-unknown-family", + GNUNET_TIME_UNIT_HOURS, + 1, + &(const struct TALER_MERCHANT_FountainGrant) { + .token_family_slug = "no-such-token-family", + .tokens_per_period_limit = 1, + .tokens_per_period_stash = 1, + .key_window_size = 0 + }), + TALER_TESTING_cmd_merchant_post_fountains ( + "create-fountain-other", + merchant_url, + MHD_HTTP_OK, + "campaign-reference-2", + GNUNET_TIME_UNIT_HOURS, + 1, + fountain_grants_other), + /* Withdraw a token from the fountain and unblind+verify it. */ + TALER_TESTING_cmd_merchant_fountain_withdraw ( + "fountain-withdraw-token", + merchant_url, + MHD_HTTP_OK, + "create-fountain", + "subscription-1", + GNUNET_TIME_UNIT_ZERO_TS, + 1), + /* Spend the fountain-issued token in an order: proves that a + token withdrawn without an order is accepted at payment. */ + TALER_TESTING_cmd_merchant_post_orders_choices ( + "create-order-with-fountain-input", + cred.cfg, + merchant_url, + MHD_HTTP_OK, + "subscription-1", + "A choice in the contract", + NULL, + 1, + 0, + "5-fountain-input", + GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, + "EUR:0.0"), + TALER_TESTING_cmd_merchant_pay_order_choices ( + "pay-order-with-fountain-input", + merchant_url, + MHD_HTTP_OK, + "create-order-with-fountain-input", + "", + "EUR:0", + "EUR:0", + NULL, + 0, + "fountain-withdraw-token"), + /* The remaining quota of the period is 2 tokens, so 3 more is + one too many and must be refused as a whole. */ + TALER_TESTING_cmd_merchant_fountain_withdraw ( + "fountain-withdraw-over-limit", + merchant_url, + MHD_HTTP_TOO_MANY_REQUESTS, + "create-fountain", + "subscription-1", + GNUNET_TIME_UNIT_ZERO_TS, + 3), + /* ... and exactly the remaining 2 must still succeed, proving + the refused request consumed no quota. */ + TALER_TESTING_cmd_merchant_fountain_withdraw ( + "fountain-withdraw-rest-of-period", + merchant_url, + MHD_HTTP_OK, + "create-fountain", + "subscription-1", + GNUNET_TIME_UNIT_ZERO_TS, + 2), + /* Now the period is exhausted. */ + TALER_TESTING_cmd_merchant_fountain_withdraw ( + "fountain-withdraw-exhausted", + merchant_url, + MHD_HTTP_TOO_MANY_REQUESTS, + "create-fountain", + "subscription-1", + GNUNET_TIME_UNIT_ZERO_TS, + 1), + /* Exercise discount issuance and spending with the same helpers. */ + TALER_TESTING_cmd_merchant_post_tokenfamilies ( + "create-fountain-discount-family", + merchant_url, + MHD_HTTP_NO_CONTENT, + "fountain-discount", + "Discount", + "A promotional discount.", + NULL, + GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_YEARS), + GNUNET_TIME_UNIT_MONTHS, + GNUNET_TIME_UNIT_MONTHS, + "discount"), + TALER_TESTING_cmd_merchant_post_fountains ( + "create-discount-fountain", + merchant_url, + MHD_HTTP_OK, + "discount-campaign", + GNUNET_TIME_UNIT_HOURS, + 1, + &(const struct TALER_MERCHANT_FountainGrant) { + .token_family_slug = "fountain-discount", + .tokens_per_period_limit = 1, + .tokens_per_period_stash = 1, + .key_window_size = 0 + }), + TALER_TESTING_cmd_merchant_fountain_withdraw ( + "fountain-withdraw-discount", + merchant_url, + MHD_HTTP_OK, + "create-discount-fountain", + "fountain-discount", + GNUNET_TIME_UNIT_ZERO_TS, + 1), + TALER_TESTING_cmd_merchant_post_orders_choices ( + "create-order-with-fountain-discount", + cred.cfg, + merchant_url, + MHD_HTTP_OK, + "fountain-discount", + "Redeem promotional discount", + NULL, + 1, + 0, + "5-fountain-discount", + GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, + "EUR:0.0"), + TALER_TESTING_cmd_merchant_pay_order_choices ( + "pay-order-with-fountain-discount", + merchant_url, + MHD_HTTP_OK, + "create-order-with-fountain-discount", + "", + "EUR:0", + "EUR:0", + NULL, + 0, + "fountain-withdraw-discount"), + /* Withdrawing from a family the fountain does not grant is + covered by test_merchant_fountains.sh, which can post a + withdraw request without first resolving an issue key. */ TALER_TESTING_cmd_end () }; diff --git a/src/testing/test_merchant_fountains.sh b/src/testing/test_merchant_fountains.sh @@ -0,0 +1,879 @@ +#!/usr/bin/env bash +# This file is part of TALER +# Copyright (C) 2026 Taler Systems SA +# +# 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. +# +# 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 TALER; see the file COPYING. If not, see +# <http://www.gnu.org/licenses/> +# + +# Exercises the token fountain endpoints (DD 98): private CRUD, +# GET /fountain/info, withdrawal validation and replay after family deletion. +# Unblinding and spending withdrawn tokens are exercised by the C harness. + +# Cleanup to run whenever we exit +function my_cleanup() +{ + for n in $(jobs -p) + do + kill "$n" 2> /dev/null || true + done + wait + if [ -n "${LAST_RESPONSE+x}" ] + then + rm -f "${LAST_RESPONSE}" + fi +} + +. setup.sh + +setup -c test_template.conf -m +CONF="test_template.conf.edited" +LAST_RESPONSE=$(mktemp -p "${TMPDIR:-/tmp}" test_response.conf-XXXXXX) +MERCHANT_URL="http://localhost:9966" + +echo -n "Configuring 'admin' instance ..." >&2 + +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + "$MERCHANT_URL/management/instances" \ + -d '{"auth":{"method":"token","password":"new_pw"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 3600000000},"default_pay_delay":{"d_us": 3600000000}}' \ + -w "%{http_code}" \ + -s \ + -o /dev/null) + +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204, instance created. got: $STATUS" >&2 +fi + +BASIC_AUTH=$(echo -n admin:new_pw | base64) + +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H "Authorization: Basic $BASIC_AUTH" \ + "$MERCHANT_URL/private/token" \ + -d '{"scope":"spa"}' \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK. Got: $STATUS" +fi + +BEARER_TOKEN=$(jq -e -r .access_token < "$LAST_RESPONSE") + +echo " OK" >&2 + +# +# CREATE TOKEN FAMILIES FOR THE FOUNTAIN GRANTS +# +echo -n "Creating discount token family..." >&2 +VALID_AFTER="{\"t_s\": $(date +%s)}" # now +VALID_BEFORE="{\"t_s\": $(date +%s -d "+300 days")}" # 300 days from now +DURATION="{\"d_us\": $(expr 3 \* 60 \* 1000000)}" # 3 minutes +GRANULARITY="{\"d_us\": $(expr 60 \* 1000000)}" # 1 minute +STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ + -X POST \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"kind\": \"discount\", \"slug\":\"test-discount\", \"name\": \"Test discount\", \"description\": \"Less money\", \"valid_after\": $VALID_AFTER, \"valid_before\": $VALID_BEFORE, \"duration\": $DURATION, \"validity_granularity\": $GRANULARITY}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '204' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Creating subscription token family..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ + -X POST \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"kind\": \"subscription\", \"slug\":\"test-subscription\", \"name\": \"Test subscription\", \"description\": \"Monthly pass\", \"valid_after\": $VALID_AFTER, \"valid_before\": $VALID_BEFORE, \"duration\": $DURATION, \"validity_granularity\": $GRANULARITY}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '204' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +# +# CREATE A FOUNTAIN +# +echo -n "Creating fountain..." >&2 +POLL_FREQ="{\"d_us\": 3600000000}" # 1 hour +STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -X POST \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"description\": \"campaign-reference-42\", \"poll_freq\": $POLL_FREQ, \"grants\": []}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +FOUNTAIN_ID=$(jq -e -r .fountain_id < "$LAST_RESPONSE") +FOUNTAIN_SECRET=$(jq -e -r .fountain_secret < "$LAST_RESPONSE") +echo "Ok (id: $FOUNTAIN_ID)" >&2 + +echo -n "Empty fountain remains accessible while grants are added and cleared..." >&2 +FOUNTAIN_GRANTS='[{"token_family_slug":"test-discount","tokens_per_period_limit":5,"tokens_per_period_stash":2,"key_window_size":2},{"token_family_slug":"test-subscription","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":1}]' +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FOUNTAIN_SECRET" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] || ! jq -e '.grants == []' "$LAST_RESPONSE" > /dev/null +then + exit_fail "Expected accessible empty fountain after creation" +fi +for EXPECTED_GRANTS in '[]' "$FOUNTAIN_GRANTS" '[]' "$FOUNTAIN_GRANTS" +do + STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"grants\": $EXPECTED_GRANTS}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "204" ] + then + exit_fail "Expected 204 updating fountain grants, got $STATUS" + fi + STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FOUNTAIN_SECRET" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "200" ] || ! jq -e --argjson expected "$EXPECTED_GRANTS" \ + '(.grants | map(.token_family_slug) | sort) == ($expected | map(.token_family_slug) | sort)' \ + "$LAST_RESPONSE" > /dev/null + then + exit_fail "Fountain info did not reflect the updated grants" + fi +done +echo "Ok" >&2 + +echo -n "Patching poll frequency and preserving it when omitted..." >&2 +for PATCH_BODY in '{"poll_freq":{"d_us":60000000}}' '{"description":"campaign-reference-42"}' '{}' +do + STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "$PATCH_BODY" -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "204" ] + then + exit_fail "Expected 204 for fountain PATCH, got $STATUS" + fi + STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "200" ] || ! jq -e '.poll_freq.d_us == 60000000' "$LAST_RESPONSE" > /dev/null + then + exit_fail "PATCH did not set or preserve poll_freq" + fi +done +# Restore the original polling frequency for the remaining CRUD checks. +STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"poll_freq\": $POLL_FREQ}" -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204 restoring poll_freq, got $STATUS" +fi +echo "Ok" >&2 + +echo -n "Creating fountain with unknown token family fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -X POST \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"description\": \"broken\", \"poll_freq\": $POLL_FREQ, \"grants\": [{\"token_family_slug\": \"no-such-family\", \"tokens_per_period_limit\": 5, \"tokens_per_period_stash\": 2, \"key_window_size\": 2}]}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "404" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '404' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Creating fountain with stash above limit fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -X POST \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"description\": \"broken\", \"poll_freq\": $POLL_FREQ, \"grants\": [{\"token_family_slug\": \"test-discount\", \"tokens_per_period_limit\": 2, \"tokens_per_period_stash\": 5, \"key_window_size\": 2}]}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "400" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '400' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +# +# LIST AND INSPECT +# +echo -n "Listing fountains..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +COUNT=$(jq -e '.fountains | length' < "$LAST_RESPONSE") +if [ "$COUNT" != "1" ] +then + exit_fail "Expected 1 fountain in list, got $COUNT" +fi +echo "Ok" >&2 + +echo -n "Inspecting fountain..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +DESC=$(jq -e -r .description < "$LAST_RESPONSE") +if [ "$DESC" != "campaign-reference-42" ] +then + exit_fail "Expected description 'campaign-reference-42', got $DESC" +fi +GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE") +if [ "$GRANTS" != "2" ] +then + exit_fail "Expected 2 grants, got $GRANTS" +fi +if jq -e '.fountain_secret' < "$LAST_RESPONSE" > /dev/null 2>&1 +then + exit_fail "Fountain secret must never be returned by the private API" +fi +echo "Ok" >&2 + +echo -n "Inspecting unknown fountain fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "404" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '404' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +# +# WALLET INFO ENDPOINT +# +echo -n "Fetching fountain info with bearer secret..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FOUNTAIN_SECRET" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE") +if [ "$GRANTS" != "2" ] +then + exit_fail "Expected 2 grants in info, got $GRANTS" +fi +# At least one issue key must be offered, and at most one per slot of +# the grant's key window (slots whose validity period is covered by an +# already-listed key share that key, so fewer is legitimate). +DISCOUNT_KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE") +if [ "$DISCOUNT_KEYS" -lt 1 ] || [ "$DISCOUNT_KEYS" -gt 3 ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 1..3 issue keys for test-discount, got $DISCOUNT_KEYS" +fi +KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-subscription") | .token_family.keys | length' < "$LAST_RESPONSE") +if [ "$KEYS" -lt 1 ] || [ "$KEYS" -gt 2 ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 1..2 issue keys for test-subscription, got $KEYS" +fi +# Issue keys must be distinct. +UNIQUE_KEYS=$(jq -e '[.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys[]] | unique | length' < "$LAST_RESPONSE") +if [ "$UNIQUE_KEYS" != "$DISCOUNT_KEYS" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Duplicate issue keys advertised: $DISCOUNT_KEYS listed, $UNIQUE_KEYS distinct" +fi +echo "Ok" >&2 + +echo -n "Fetching fountain info again is idempotent..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FOUNTAIN_SECRET" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +# Re-polling must be idempotent: no additional keys may be minted. +KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE") +if [ "$KEYS" != "$DISCOUNT_KEYS" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Re-poll changed issue key count: was $DISCOUNT_KEYS, now $KEYS" +fi +echo "Ok" >&2 + +echo -n "Fetching fountain info with bad secret fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer 0000000000000000000000000000000000000000000000000000" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "401" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '401' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Fetching fountain info without secret fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "401" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '401' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +# +# WITHDRAW VALIDATION ERRORS +# +echo -n "Withdrawing with bad secret fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST \ + -d "{\"fountain_secret\": \"0000000000000000000000000000000000000000000000000000\", \"grants\": []}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "401" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '401' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Withdrawing for ungranted family fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST \ + -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"no-such-family\", \"envelopes\": []}]}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "409" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '409' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Withdrawing outside the key window fails..." >&2 +FUTURE="{\"t_s\": $(date +%s -d "+200 days")}" +# A real envelope is required: an empty "envelopes" array is rejected as a +# malformed request before the key window is ever resolved. +ENVELOPE="{\"cipher\": \"RSA\", \"rsa_blinded_planchet\": \"04\"}" +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST \ + -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"valid_at\": $FUTURE, \"envelopes\": [$ENVELOPE]}]}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +# Check the error code as well: an unknown grant also answers 409, and would +# otherwise hide a request that never reached the key window check. +if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '409' TOKEN_KEY_SLOT_OUTSIDE_WINDOW. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Withdrawing without envelopes fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST \ + -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": []}]}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "400" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '400' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +# +# PATCH +# +echo -n "Wrong envelope cipher is a bad request and does not consume quota..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ + -d '{"description":"cipher-validation","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-discount","tokens_per_period_limit":1,"tokens_per_period_stash":1,"key_window_size":0}]}' \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 creating cipher-validation fountain, got $STATUS" +fi +CIPHER_FOUNTAIN_ID=$(jq -er .fountain_id "$LAST_RESPONSE") +CIPHER_FOUNTAIN_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE") +# The family uses RSA. These zero-valued CS fields are structurally valid, +# so rejection must come from matching the envelope against the issue key. +CS_ZERO=0000000000000000000000000000000000000000000000000000 +CS_ENVELOPE=$(jq -nc --arg z "$CS_ZERO" \ + '{cipher:"CS",cs_nonce:$z,cs_blinded_c0:$z,cs_blinded_c1:$z}') +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \ + -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$CS_ENVELOPE]}]}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "400" ] || ! jq -e '.code == 26' "$LAST_RESPONSE" > /dev/null +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 400 GENERIC_PARAMETER_MALFORMED for wrong cipher, got $STATUS" +fi +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \ + -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$ENVELOPE]}]}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Wrong-cipher request consumed quota: valid withdrawal returned $STATUS" +fi +STATUS=$(curl "$MERCHANT_URL/private/fountains/$CIPHER_FOUNTAIN_ID" \ + -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204 deleting cipher-validation fountain, got $STATUS" +fi +echo "Ok" >&2 + +echo -n "Fountain quotas are restricted to nonnegative INT8 values..." >&2 +# Keep the large integer literals as text: passing them through jq arithmetic +# can round INT64_MAX and accidentally test a different input. +for QUOTAS in \ + '"tokens_per_period_limit":0,"tokens_per_period_stash":0' \ + '"tokens_per_period_limit":9223372036854775807,"tokens_per_period_stash":9223372036854775807' +do + QUOTA_GRANT="{\"token_family_slug\":\"test-discount\",$QUOTAS,\"key_window_size\":0}" + STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$QUOTA_GRANT]}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "200" ] + then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200 for valid quota boundary, got $STATUS" + fi + QUOTA_ID=$(jq -er .fountain_id "$LAST_RESPONSE") + STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \ + -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"grants\":[$QUOTA_GRANT]}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "204" ] + then + exit_fail "Expected 204 patching valid quota boundary, got $STATUS" + fi + for BAD_QUOTAS in \ + '"tokens_per_period_limit":-1,"tokens_per_period_stash":0' \ + '"tokens_per_period_limit":0,"tokens_per_period_stash":-1' \ + '"tokens_per_period_limit":-1,"tokens_per_period_stash":-1' \ + '"tokens_per_period_limit":-9223372036854775808,"tokens_per_period_stash":0' \ + '"tokens_per_period_limit":9223372036854775808,"tokens_per_period_stash":0' + do + BAD_GRANT="{\"token_family_slug\":\"test-discount\",$BAD_QUOTAS,\"key_window_size\":0}" + for METHOD in POST PATCH + do + QUOTA_URL="$MERCHANT_URL/private/fountains" + if [ "$METHOD" = PATCH ] + then + QUOTA_URL="$QUOTA_URL/$QUOTA_ID" + fi + STATUS=$(curl "$QUOTA_URL" -X "$METHOD" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$BAD_GRANT]}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "400" ] + then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 400 for $METHOD with $BAD_QUOTAS, got $STATUS" + fi + done + done + STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \ + -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "204" ] + then + exit_fail "Expected 204 deleting quota test fountain, got $STATUS" + fi +done +echo "Ok" >&2 + +echo -n "Offset families advertise covering keys and allow withdrawal..." >&2 +OFFSET_NOW=$(date +%s) +OFFSET_START=$((OFFSET_NOW - 172800)) +OFFSET_END=$((OFFSET_NOW + 172800)) +STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ + -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"slug\":\"test-offset\",\"name\":\"Offset family\",\"description\":\"Offset coverage\",\"kind\":\"discount\",\"valid_after\":{\"t_s\":$OFFSET_START},\"valid_before\":{\"t_s\":$OFFSET_END},\"duration\":{\"d_us\":7140000000},\"validity_granularity\":{\"d_us\":3600000000},\"start_offset\":{\"d_us\":3540000000}}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 204 creating offset family, got $STATUS" +fi +STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ + -d '{"description":"offset-coverage","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-offset","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":2}]}' \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 creating offset fountain, got $STATUS" +fi +OFFSET_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE") +OFFSET_ID=$(jq -er .fountain_id "$LAST_RESPONSE") +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $OFFSET_SECRET" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] || ! jq -e --argjson now "$OFFSET_NOW" \ + '.grants[0].token_family.keys as $keys | + ($keys | length) == 3 and + $keys[0].signature_validity_start.t_s <= $now and + $keys[0].signature_validity_end.t_s >= $now and + all(range(1;3); . as $i | + $keys[$i].signature_validity_start.t_s <= $keys[$i-1].signature_validity_end.t_s + 1 and + $keys[$i].signature_validity_end.t_s > $keys[$i-1].signature_validity_end.t_s)' \ + "$LAST_RESPONSE" > /dev/null +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Offset family did not advertise a covering key window" +fi +OFFSET_SLOTS=$(jq -r '.grants[0].token_family.keys[].signature_validity_start.t_s' "$LAST_RESPONSE") +for SLOT in $OFFSET_SLOTS +do + STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \ + -d "{\"fountain_secret\":\"$OFFSET_SECRET\",\"grants\":[{\"token_family_slug\":\"test-offset\",\"valid_at\":{\"t_s\":$SLOT},\"envelopes\":[$ENVELOPE]}]}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "200" ] + then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200 withdrawing advertised offset key, got $STATUS" + fi +done +STATUS=$(curl "$MERCHANT_URL/private/fountains/$OFFSET_ID" \ + -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204 deleting offset fountain, got $STATUS" +fi +echo "Ok" >&2 + +echo -n "Future keys are bounded by durations from now, not the family start..." >&2 +# One-hour duration and one-minute granularity distinguish which interval +# bounds prefetching. Start halfway through the first future duration. +FIRST_START=$(date +%s -d "+30 minutes") +FAMILY_REQUEST=$(jq -nc --argjson start "$FIRST_START" \ + '{slug:"test-future",name:"Future promotion",description:"Starts soon",kind:"discount",valid_after:{t_s:$start},duration:{d_us:3600000000},validity_granularity:{d_us:60000000}}') +STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ + -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204 creating future token family, got $STATUS" +fi +STATUS=$(curl "$MERCHANT_URL/private/fountains" \ + -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ + -d '{"description":"future-start","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-future","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":0}]}' \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 creating future fountain, got $STATUS" +fi +FUTURE_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE") +FUTURE_ID=$(jq -er .fountain_id "$LAST_RESPONSE") +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FUTURE_SECRET" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null +then + exit_fail "Zero-size window advertised a future key" +fi +for VALID_AT in null "$(date +%s)" "$FIRST_START" +do + WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$VALID_AT" \ + --argjson envelope "$ENVELOPE" \ + '{fountain_secret:$secret,grants:[({token_family_slug:"test-future",envelopes:[$envelope]} + (if $at == null then {} else {valid_at:{t_s:$at}} end))]}') + STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null + then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 409 for a future family with window 0, got $STATUS" + fi +done +for WINDOW in 1 2 +do + PATCH_REQUEST=$(jq -nc --argjson window "$WINDOW" \ + '{grants:[{token_family_slug:"test-future",tokens_per_period_limit:3,tokens_per_period_stash:1,key_window_size:$window}]}') + STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \ + -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "204" ] + then + exit_fail "Expected 204 changing future window, got $STATUS" + fi + STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FUTURE_SECRET" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + HORIZON=$(($(date +%s) + WINDOW * 3600)) + if [ "$STATUS" != "200" ] || ! jq -e --argjson count "$WINDOW" --argjson horizon "$HORIZON" \ + '(.grants[0].token_family.keys | length) == $count and + all(.grants[0].token_family.keys[]; .signature_validity_start.t_s <= $horizon and .signature_validity_end.t_s <= ($horizon + 3600))' \ + "$LAST_RESPONSE" > /dev/null + then + cat "$LAST_RESPONSE" >&2 + exit_fail "Future window did not respect its duration-based horizon" + fi + SLOT_START=$(jq -er '.grants[0].token_family.keys[-1].signature_validity_start.t_s' "$LAST_RESPONSE") + WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$SLOT_START" \ + --argjson envelope "$ENVELOPE" \ + '{fountain_secret:$secret,grants:[{token_family_slug:"test-future",valid_at:{t_s:$at},envelopes:[$envelope]}]}') + STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "200" ] + then + exit_fail "Expected 200 for advertised future key, got $STATUS" + fi + # The next key would start outside the horizon, even though this + # family began later than now. Neither info nor withdraw may offer it. + WITHDRAW_REQUEST=$(jq --argjson at "$((SLOT_START + 3601))" \ + '.grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST") + STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null + then + exit_fail "Expected 409 beyond the duration-based horizon, got $STATUS" + fi +done +# Even with a nonzero window, a family starting four durations ahead +# must not shift the horizon to its own first key. +FAR_START=$(date +%s -d "+4 hours") +FAMILY_REQUEST=$(jq --argjson start "$FAR_START" \ + '.slug = "test-far-future" | .valid_after = {t_s:$start}' <<< "$FAMILY_REQUEST") +STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ + -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204 creating far-future family, got $STATUS" +fi +PATCH_REQUEST=$(jq '.grants[0].token_family_slug = "test-far-future"' <<< "$PATCH_REQUEST") +STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \ + -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204 changing granted family, got $STATUS" +fi +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FUTURE_SECRET" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null +then + exit_fail "Far-future family shifted the advertised horizon" +fi +WITHDRAW_REQUEST=$(jq --argjson at "$FAR_START" \ + '.grants[0].token_family_slug = "test-far-future" | .grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST") +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null +then + exit_fail "Expected 409 for far-future family, got $STATUS" +fi +echo "Ok" >&2 + +echo -n "Saving a two-family withdrawal for replay..." >&2 +REPLAY_REQUEST="{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": [$ENVELOPE]}, {\"token_family_slug\": \"test-subscription\", \"envelopes\": [$ENVELOPE]}]}" +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST -d "$REPLAY_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] || ! jq -e '.grants | length == 2' "$LAST_RESPONSE" > /dev/null +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected a successful two-family withdrawal, got $STATUS" +fi +REPLAY_RESPONSE=$(jq -cS . "$LAST_RESPONSE") +echo "Ok" >&2 + +echo -n "Patching fountain (drop subscription grant)..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -X PATCH \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"description\": \"campaign-reference-43\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"tokens_per_period_limit\": 7, \"tokens_per_period_stash\": 3, \"key_window_size\": 2}]}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '204' response. Got instead $STATUS" +fi +STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +DESC=$(jq -e -r .description < "$LAST_RESPONSE") +if [ "$DESC" != "campaign-reference-43" ] +then + exit_fail "Expected patched description, got $DESC" +fi +GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE") +if [ "$GRANTS" != "1" ] +then + exit_fail "Expected 1 grant after patch, got $GRANTS" +fi +echo "Ok" >&2 + +echo -n "Withdrawing for dropped grant fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST \ + -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-subscription\", \"envelopes\": []}]}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "409" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '409' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Patching unknown fountain fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \ + -X PATCH \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{\"description\": \"nope\"}" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "404" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '404' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +# +# DELETE +# +echo -n "Replaying the complete response after token family deletion..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies/test-subscription" \ + -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected token family deletion to return 204, got $STATUS" +fi +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST -d "$REPLAY_REQUEST" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] || [ "$(jq -cS . "$LAST_RESPONSE")" != "$REPLAY_RESPONSE" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Family deletion changed the original withdrawal response ($STATUS)" +fi +echo "Ok" >&2 + +echo -n "Deleting fountain..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -X DELETE \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '204' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Fountain info after deletion fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/info" \ + -H "Authorization: Bearer $FOUNTAIN_SECRET" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "401" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '401' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo -n "Withdrawal after deletion fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ + -X POST \ + -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": []}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") +if [ "$STATUS" != "401" ] +then + exit_fail "Expected 401 withdrawing from deleted fountain, got $STATUS" +fi +echo "Ok" >&2 + +echo -n "Deleting unknown fountain fails..." >&2 +STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ + -X DELETE \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "404" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected '404' response. Got instead $STATUS" +fi +echo "Ok" >&2 + +echo "Test PASSED" >&2 +exit 0 diff --git a/src/testing/testing_api_cmd_fountain_withdraw.c b/src/testing/testing_api_cmd_fountain_withdraw.c @@ -0,0 +1,616 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 TALER; see the file COPYING. If not, see + <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/testing/testing_api_cmd_fountain_withdraw.c + * @brief command simulating a wallet withdrawing tokens from a + * fountain (DD 98): fetches GET /fountain/info, prepares + * blinded envelopes, runs POST /fountain/withdraw and + * unblinds and verifies the resulting tokens + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +struct FountainWithdrawState; +#define TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE \ + struct FountainWithdrawState +#define TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE \ + struct FountainWithdrawState +#include <gnunet/gnunet_time_lib.h> +#include <taler/taler_exchange_service.h> +#include <taler/taler_testing_lib.h> +#include "taler/taler_merchant_service.h" +#include "taler/taler_merchant_testing_lib.h" +#include <taler/merchant/get-fountain-info.h> +#include <taler/merchant/post-fountain-withdraw.h> + + +/** + * Client-side state of one token being withdrawn. + */ +struct FountainToken +{ + + /** + * Master secret used to derive the private key from. + */ + struct TALER_TokenUseMasterSecretP master; + + /** + * Private key of the token. + */ + struct TALER_TokenUsePrivateKeyP token_priv; + + /** + * Public key of the token. + */ + struct TALER_TokenUsePublicKeyP token_pub; + + /** + * Hash of the public key of the token. + */ + struct TALER_TokenUsePublicKeyHashP h_token_pub; + + /** + * Blinded public key of the token. + */ + struct TALER_TokenEnvelope envelope; + + /** + * Value used to blind the key for the signature. + */ + union GNUNET_CRYPTO_BlindingSecretP blinding_secret; + + /** + * Inputs needed from the merchant for blind signing. + */ + struct TALER_TokenUseMerchantValues blinding_inputs; + + /** + * Token issue public key. + */ + struct TALER_TokenIssuePublicKey issue_pub; + + /** + * Unblinded token issue signature made by the merchant. + */ + struct TALER_TokenIssueSignature issue_sig; + +}; + + +/** + * State of a fountain withdraw CMD. + */ +struct FountainWithdrawState +{ + + /** + * Expected status code of the withdraw request. + */ + unsigned int http_status; + + /** + * Handle for the "GET /fountain/info" request. + */ + struct TALER_MERCHANT_GetFountainInfoHandle *info_handle; + + /** + * Handle for the "POST /fountain/withdraw" request. + */ + struct TALER_MERCHANT_PostFountainWithdrawHandle *withdraw_handle; + + /** + * The interpreter state. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Base URL of the merchant serving the request. + */ + const char *merchant_url; + + /** + * Label of the command holding the fountain secret. + */ + const char *fountain_reference; + + /** + * Slug of the token family to withdraw from. + */ + const char *token_family_slug; + + /** + * Desired token validity time; zero for "now". + */ + struct GNUNET_TIME_Timestamp valid_at; + + /** + * The fountain's bearer credential (from the referenced command). + */ + const char *fountain_secret; + + /** + * Tokens being withdrawn. + */ + struct FountainToken *tokens; + + /** + * Number of tokens in @e tokens. + */ + unsigned int num_tokens; + + /** + * First successful response, retained to check an identical retry. + */ + json_t *first_reply; +}; + + +/** + * Submit the prepared envelopes, including when replaying a request. + * + * @param state our command state + */ +static void +start_withdraw (struct FountainWithdrawState *state); + + +/** + * Callback for the POST /fountain/withdraw operation: unblinds and + * verifies the tokens on success. + * + * @param state our command state + * @param fwr response being processed + */ +static void +withdraw_cb (struct FountainWithdrawState *state, + const struct TALER_MERCHANT_PostFountainWithdrawResponse *fwr) +{ + state->withdraw_handle = NULL; + if (state->http_status != fwr->hr.http_status) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u (%d) to command %s\n", + fwr->hr.http_status, + (int) fwr->hr.ec, + TALER_TESTING_interpreter_get_current_label (state->is)); + TALER_TESTING_interpreter_fail (state->is); + return; + } + if (MHD_HTTP_OK == fwr->hr.http_status) + { + const struct TALER_MERCHANT_FountainWithdrawResult *res; + + if (NULL == state->first_reply) + { + state->first_reply = json_incref ((json_t *) fwr->hr.reply); + start_withdraw (state); + return; + } + if (! json_equal (state->first_reply, + fwr->hr.reply)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Fountain retry returned different signatures\n"); + TALER_TESTING_interpreter_fail (state->is); + return; + } + if (1 != fwr->details.ok.results_len) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (state->is); + return; + } + res = &fwr->details.ok.results[0]; + /* The reported issue key must be the one we blinded against; + otherwise the signatures cannot verify. */ + if (0 != GNUNET_memcmp (&res->h_issue.hash, + &state->tokens[0].issue_pub.public_key-> + pub_key_hash)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Merchant signed with issue key %s, but we blinded against %s\n", + GNUNET_h2s (&res->h_issue.hash), + GNUNET_h2s2 (&state->tokens[0].issue_pub.public_key-> + pub_key_hash)); + GNUNET_break (0); + TALER_TESTING_interpreter_fail (state->is); + return; + } + if (res->num_sigs != state->num_tokens) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Sent %u envelopes but got %u blind signatures\n", + state->num_tokens, + res->num_sigs); + TALER_TESTING_interpreter_fail (state->is); + return; + } + for (unsigned int i = 0; i < state->num_tokens; i++) + { + struct FountainToken *token = &state->tokens[i]; + + /* The signatures are in the same order as the envelopes. */ + if (GNUNET_OK != + TALER_token_issue_sig_unblind (&token->issue_sig, + &res->token_sigs[i], + &token->blinding_secret, + &token->h_token_pub, + &token->blinding_inputs, + &token->issue_pub)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to unblind token signature\n"); + GNUNET_break (0); + TALER_TESTING_interpreter_fail (state->is); + return; + } + if (GNUNET_OK != + TALER_token_issue_verify (&token->token_pub, + &token->issue_pub, + &token->issue_sig)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unblinded token signature is invalid\n"); + GNUNET_break (0); + TALER_TESTING_interpreter_fail (state->is); + return; + } + } + } + TALER_TESTING_interpreter_next (state->is); +} + + +static void +start_withdraw (struct FountainWithdrawState *state) +{ + struct TALER_TokenEnvelope envelopes[state->num_tokens]; + struct TALER_MERCHANT_FountainWithdrawEntry entry = { + .token_family_slug = state->token_family_slug, + .valid_at = state->valid_at, + .num_envelopes = state->num_tokens, + .envelopes = envelopes + }; + enum TALER_ErrorCode ec; + + for (unsigned int i = 0; i < state->num_tokens; i++) + envelopes[i] = state->tokens[i].envelope; + state->withdraw_handle = TALER_MERCHANT_post_fountain_withdraw_create ( + TALER_TESTING_interpreter_get_context (state->is), + state->merchant_url, + state->fountain_secret, + 1, + &entry); + ec = TALER_MERCHANT_post_fountain_withdraw_start ( + state->withdraw_handle, + &withdraw_cb, + state); + GNUNET_assert (TALER_EC_NONE == ec); +} + + +/** + * Callback for the GET /fountain/info operation: selects the issue + * key, blinds the token envelopes and starts the withdraw request. + * + * @param state our command state + * @param fir response being processed + */ +static void +info_cb (struct FountainWithdrawState *state, + const struct TALER_MERCHANT_GetFountainInfoResponse *fir) +{ + const struct TALER_MERCHANT_FountainWalletGrant *grant = NULL; + const struct TALER_MERCHANT_ContractTokenFamilyKey *key = NULL; + struct GNUNET_TIME_Timestamp valid_at = state->valid_at; + + state->info_handle = NULL; + if (MHD_HTTP_OK != fir->hr.http_status) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "GET /fountain/info failed with response code %u (%d) in command %s\n", + fir->hr.http_status, + (int) fir->hr.ec, + TALER_TESTING_interpreter_get_current_label (state->is)); + TALER_TESTING_interpreter_fail (state->is); + return; + } + if (GNUNET_TIME_absolute_is_zero (valid_at.abs_time)) + valid_at = GNUNET_TIME_timestamp_get (); + for (unsigned int i = 0; i < fir->details.ok.grants_len; i++) + { + if (0 == strcmp (fir->details.ok.grants[i].grant.token_family_slug, + state->token_family_slug)) + { + grant = &fir->details.ok.grants[i]; + break; + } + } + if (NULL == grant) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "No grant for token family %s in fountain info\n", + state->token_family_slug); + TALER_TESTING_interpreter_fail (state->is); + return; + } + /* An explicit advertised start selects that key, even in an overlap. + An omitted valid_at selects the first advertised key. */ + if (GNUNET_TIME_absolute_is_zero (state->valid_at.abs_time)) + { + if (0 != grant->token_family.keys_len) + key = &grant->token_family.keys[0]; + } + else + for (unsigned int i = 0; i < grant->token_family.keys_len; i++) + if (GNUNET_TIME_timestamp_cmp (grant->token_family.keys[i].valid_after, + ==, + valid_at)) + { + key = &grant->token_family.keys[i]; + break; + } + for (unsigned int i = 0; (NULL == key) && (i < grant->token_family.keys_len); i++) + { + const struct TALER_MERCHANT_ContractTokenFamilyKey *k + = &grant->token_family.keys[i]; + + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Fountain info key %u: %s valid %llu - %llu (want %llu)\n", + i, + GNUNET_h2s (&k->pub.public_key->pub_key_hash), + (unsigned long long) k->valid_after.abs_time.abs_value_us, + (unsigned long long) k->valid_before.abs_time.abs_value_us, + (unsigned long long) valid_at.abs_time.abs_value_us); + if ( (GNUNET_TIME_timestamp_cmp (k->valid_after, + <=, + valid_at)) && + (GNUNET_TIME_timestamp_cmp (valid_at, + <=, + k->valid_before)) ) + { + key = k; + break; + } + } + if (NULL == key) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "No issue key covering the desired validity time in fountain info\n"); + TALER_TESTING_interpreter_fail (state->is); + return; + } + /* Prepare the blinded envelopes. */ + for (unsigned int i = 0; i < state->num_tokens; i++) + { + struct FountainToken *token = &state->tokens[i]; + + TALER_token_issue_pub_copy (&token->issue_pub, + &key->pub); + /* Only RSA is supported for now. */ + GNUNET_assert (GNUNET_CRYPTO_BSA_RSA == + token->issue_pub.public_key->cipher); + TALER_token_blind_input_copy (&token->blinding_inputs, + TALER_token_blind_input_rsa_singleton ()); + TALER_token_use_setup_random (&token->master); + TALER_token_use_setup_priv (&token->master, + &token->blinding_inputs, + &token->token_priv); + TALER_token_use_blinding_secret_create (&token->master, + &token->blinding_inputs, + &token->blinding_secret); + GNUNET_CRYPTO_eddsa_key_get_public ( + &token->token_priv.private_key, + &token->token_pub.public_key); + GNUNET_CRYPTO_hash (&token->token_pub.public_key, + sizeof (struct GNUNET_CRYPTO_EcdsaPublicKey), + &token->h_token_pub.hash); + token->envelope.blinded_pub = + GNUNET_CRYPTO_message_blind_to_sign ( + token->issue_pub.public_key, + &token->blinding_secret, + NULL, /* session nonce, only needed for CS */ + &token->h_token_pub.hash, + sizeof (token->h_token_pub.hash), + token->blinding_inputs.blinding_inputs); + if (NULL == token->envelope.blinded_pub) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (state->is); + return; + } + } + start_withdraw (state); +} + + +/** + * Run the fountain withdraw CMD. + * + * @param cls closure. + * @param cmd command being run now. + * @param is interpreter state. + */ +static void +fountain_withdraw_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct FountainWithdrawState *state = cls; + const struct TALER_TESTING_Command *fountain_cmd; + + state->is = is; + fountain_cmd = TALER_TESTING_interpreter_lookup_command ( + is, + state->fountain_reference); + if (NULL == fountain_cmd) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); + return; + } + if (GNUNET_OK != + TALER_TESTING_get_trait_fountain_secret (fountain_cmd, + &state->fountain_secret)) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); + return; + } + state->info_handle = TALER_MERCHANT_get_fountain_info_create ( + TALER_TESTING_interpreter_get_context (is), + state->merchant_url, + state->fountain_secret); + { + enum TALER_ErrorCode ec; + + ec = TALER_MERCHANT_get_fountain_info_start ( + state->info_handle, + &info_cb, + state); + GNUNET_assert (TALER_EC_NONE == ec); + } +} + + +/** + * Offers information from the fountain withdraw CMD state to other + * commands; in particular the withdrawn tokens can be spent by + * referencing this command from a pay command's token reference. + * + * @param cls closure + * @param[out] ret result (could be anything) + * @param trait name of the trait + * @param index index number of the object to extract. + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +fountain_withdraw_traits (void *cls, + const void **ret, + const char *trait, + unsigned int index) +{ + struct FountainWithdrawState *state = cls; + + if (index >= state->num_tokens) + return GNUNET_SYSERR; + { + struct TALER_TESTING_Trait traits[] = { + TALER_TESTING_make_trait_token_priv ( + index, + &state->tokens[index].token_priv), + TALER_TESTING_make_trait_token_issue_pub ( + index, + &state->tokens[index].issue_pub), + TALER_TESTING_make_trait_token_issue_sig ( + index, + &state->tokens[index].issue_sig), + TALER_TESTING_trait_end () + }; + + return TALER_TESTING_get_trait (traits, + ret, + trait, + index); + } +} + + +/** + * Free the state of a fountain withdraw CMD, and possibly cancel + * pending operations thereof. + * + * @param cls closure. + * @param cmd command being run. + */ +static void +fountain_withdraw_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct FountainWithdrawState *state = cls; + + if (NULL != state->info_handle) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "GET /fountain/info operation did not complete\n"); + TALER_MERCHANT_get_fountain_info_cancel (state->info_handle); + } + if (NULL != state->withdraw_handle) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "POST /fountain/withdraw operation did not complete\n"); + TALER_MERCHANT_post_fountain_withdraw_cancel (state->withdraw_handle); + } + for (unsigned int i = 0; i < state->num_tokens; i++) + { + struct FountainToken *token = &state->tokens[i]; + + if (NULL != token->issue_pub.public_key) + GNUNET_CRYPTO_blind_sign_pub_decref (token->issue_pub.public_key); + if (NULL != token->envelope.blinded_pub) + GNUNET_CRYPTO_blinded_message_decref (token->envelope.blinded_pub); + if (NULL != token->issue_sig.signature) + GNUNET_CRYPTO_unblinded_sig_decref (token->issue_sig.signature); + /* Note: blinding_inputs is a plain copy of the RSA singleton + (a static without refcount), so it must not be decref'ed. */ + } + GNUNET_free (state->tokens); + json_decref (state->first_reply); + GNUNET_free (state); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_fountain_withdraw ( + const char *label, + const char *merchant_url, + unsigned int http_status, + const char *fountain_reference, + const char *token_family_slug, + struct GNUNET_TIME_Timestamp valid_at, + unsigned int num_tokens) +{ + struct FountainWithdrawState *state; + + GNUNET_assert (num_tokens > 0); + state = GNUNET_new (struct FountainWithdrawState); + state->merchant_url = merchant_url; + state->http_status = http_status; + state->fountain_reference = fountain_reference; + state->token_family_slug = token_family_slug; + state->valid_at = valid_at; + state->num_tokens = num_tokens; + state->tokens = GNUNET_new_array (num_tokens, + struct FountainToken); + { + struct TALER_TESTING_Command cmd = { + .cls = state, + .label = label, + .run = &fountain_withdraw_run, + .cleanup = &fountain_withdraw_cleanup, + .traits = &fountain_withdraw_traits + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_fountain_withdraw.c */ diff --git a/src/testing/testing_api_cmd_post_fountains.c b/src/testing/testing_api_cmd_post_fountains.c @@ -0,0 +1,261 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + 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. + + 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 TALER; see the file COPYING. If not, see + <http://www.gnu.org/licenses/> +*/ + +/** + * @file src/testing/testing_api_cmd_post_fountains.c + * @brief command to run POST /private/fountains (DD 98) + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +struct PostFountainsState; +#define TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE \ + struct PostFountainsState +#include <gnunet/gnunet_time_lib.h> +#include <taler/taler_exchange_service.h> +#include <taler/taler_testing_lib.h> +#include "taler/taler_merchant_service.h" +#include "taler/taler_merchant_testing_lib.h" +#include <taler/merchant/post-private-fountains.h> + + +/** + * State of a "POST /private/fountains" CMD. + */ +struct PostFountainsState +{ + + /** + * Expected status code. + */ + unsigned int http_status; + + /** + * Handle for a "POST /private/fountains" request. + */ + struct TALER_MERCHANT_PostPrivateFountainsHandle *handle; + + /** + * The interpreter state. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Base URL of the merchant serving the request. + */ + const char *merchant_url; + + /** + * Description of the fountain. + */ + const char *description; + + /** + * Poll frequency of the fountain. + */ + struct GNUNET_TIME_Relative poll_freq; + + /** + * Grants of the fountain. + */ + const struct TALER_MERCHANT_FountainGrant *grants; + + /** + * Length of the @e grants array. + */ + unsigned int num_grants; + + /** + * Identifier of the created fountain. + */ + char *fountain_id; + + /** + * Bearer credential of the created fountain. + */ + char *fountain_secret; +}; + + +/** + * Callback for a POST /private/fountains operation. + * + * @param cls closure for this function + * @param pfr response being processed + */ +static void +post_fountains_cb (struct PostFountainsState *state, + const struct + TALER_MERCHANT_PostPrivateFountainsResponse *pfr) +{ + + state->handle = NULL; + if (state->http_status != pfr->hr.http_status) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response code %u (%d) to command %s\n", + pfr->hr.http_status, + (int) pfr->hr.ec, + TALER_TESTING_interpreter_get_current_label (state->is)); + TALER_TESTING_interpreter_fail (state->is); + return; + } + switch (pfr->hr.http_status) + { + case MHD_HTTP_OK: + state->fountain_id = GNUNET_strdup (pfr->details.ok.fountain_id); + state->fountain_secret = GNUNET_strdup (pfr->details.ok.fountain_secret); + break; + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + break; + default: + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Unhandled HTTP status %u for POST /private/fountains.\n", + pfr->hr.http_status); + } + TALER_TESTING_interpreter_next (state->is); +} + + +/** + * Run the "POST /private/fountains" CMD. + * + * @param cls closure. + * @param cmd command being run now. + * @param is interpreter state. + */ +static void +post_fountains_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct PostFountainsState *state = cls; + + state->is = is; + state->handle = TALER_MERCHANT_post_private_fountains_create ( + TALER_TESTING_interpreter_get_context (is), + state->merchant_url, + state->description, + state->poll_freq, + state->num_grants, + state->grants); + { + enum TALER_ErrorCode ec; + + ec = TALER_MERCHANT_post_private_fountains_start ( + state->handle, + &post_fountains_cb, + state); + GNUNET_assert (TALER_EC_NONE == ec); + } +} + + +/** + * Offers information from the "POST /private/fountains" CMD state to + * other commands. + * + * @param cls closure + * @param[out] ret result (could be anything) + * @param trait name of the trait + * @param index index number of the object to extract. + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +post_fountains_traits (void *cls, + const void **ret, + const char *trait, + unsigned int index) +{ + struct PostFountainsState *state = cls; + struct TALER_TESTING_Trait traits[] = { + TALER_TESTING_make_trait_fountain_id (state->fountain_id), + TALER_TESTING_make_trait_fountain_secret (state->fountain_secret), + TALER_TESTING_trait_end () + }; + + return TALER_TESTING_get_trait (traits, + ret, + trait, + index); +} + + +/** + * Free the state of a "POST /private/fountains" CMD, and possibly + * cancel a pending operation thereof. + * + * @param cls closure. + * @param cmd command being run. + */ +static void +post_fountains_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct PostFountainsState *state = cls; + + if (NULL != state->handle) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "POST /private/fountains operation did not complete\n"); + TALER_MERCHANT_post_private_fountains_cancel (state->handle); + } + GNUNET_free (state->fountain_id); + GNUNET_free (state->fountain_secret); + GNUNET_free (state); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_fountains ( + const char *label, + const char *merchant_url, + unsigned int http_status, + const char *description, + struct GNUNET_TIME_Relative poll_freq, + unsigned int num_grants, + const struct TALER_MERCHANT_FountainGrant *grants) +{ + struct PostFountainsState *state; + + state = GNUNET_new (struct PostFountainsState); + state->merchant_url = merchant_url; + state->http_status = http_status; + state->description = description; + state->poll_freq = poll_freq; + state->num_grants = num_grants; + state->grants = grants; + { + struct TALER_TESTING_Command cmd = { + .cls = state, + .label = label, + .run = &post_fountains_run, + .cleanup = &post_fountains_cleanup, + .traits = &post_fountains_traits + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_post_fountains.c */ diff --git a/src/util/token_family_parse.c b/src/util/token_family_parse.c @@ -177,6 +177,15 @@ parse_token_details (void *cls, return GNUNET_SYSERR; } + /* Check the size_t count before allocation or conversion to the + unsigned array length. A remote response must not reach the + allocator's fatal size limit. */ + if (json_array_size (trusted_domains) > + TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } GNUNET_array_grow (family->details.subscription.trusted_domains, family->details.subscription.trusted_domains_len, json_array_size (trusted_domains)); @@ -224,6 +233,12 @@ parse_token_details (void *cls, return GNUNET_SYSERR; } + if (json_array_size (expected_domains) > + TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } GNUNET_array_grow (family->details.discount.expected_domains, family->details.discount.expected_domains_len, json_array_size (expected_domains)); @@ -345,6 +360,12 @@ parse_token_families (void *cls, return GNUNET_SYSERR; } + if (json_array_size (keys) > TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS) + { + GNUNET_break_op (0); + TALER_MERCHANT_contract_token_family_free (&family); + return GNUNET_SYSERR; + } GNUNET_array_grow (family.keys, family.keys_len, json_array_size (keys));