commit 385888369d04884a0a95c7299c5825836d96ba70
parent 8260a2cb4a8953cedb11b326a1df5dacb492b7ea
Author: Christian Grothoff <christian@grothoff.org>
Date: Sat, 10 Oct 2026 17:50:56 +0200
limit unauthenticated password reset attempts to one every 15 minutes
Diffstat:
9 files changed, 367 insertions(+), 36 deletions(-)
diff --git a/src/backend/taler-merchant-httpd_mfa.c b/src/backend/taler-merchant-httpd_mfa.c
@@ -27,6 +27,7 @@
#include "taler-merchant-httpd_mfa.h"
#include "merchant-database/insert_mfa_challenge.h"
#include "merchant-database/get_mfa_challenge.h"
+#include "merchant-database/update_instance_password_reset.h"
/**
@@ -39,6 +40,13 @@
*/
#define CHALLENGE_LIFETIME GNUNET_TIME_UNIT_DAYS
+/**
+ * How long after a password reset of an instance was started can
+ * the next one be started at the earliest?
+ */
+#define PASSWORD_RESET_DELAY \
+ GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MINUTES, 15)
+
enum GNUNET_GenericReturnValue
TMH_mfa_parse_challenge_id (struct TMH_HandlerContext *hc,
@@ -415,27 +423,99 @@ get_hint (enum TALER_MERCHANT_MFA_Channel channel,
/**
+ * New challenges are about to be started for a password reset of
+ * @a instance_name. A password reset is unauthenticated, so without a
+ * limit anyone could start one reset after the other and guess the TAN
+ * sent for each. Allow it only if the last password reset of the
+ * instance was started at least #PASSWORD_RESET_DELAY ago, and record
+ * this one.
+ *
+ * @param[in,out] hc handler context with the connection to the client
+ * @param instance_name instance whose password is being reset
+ * @return #GNUNET_OK if new challenges may be started,
+ * #GNUNET_NO if an error message was returned to the client
+ * #GNUNET_SYSERR to just close the connection
+ */
+static enum GNUNET_GenericReturnValue
+password_reset_start (struct TMH_HandlerContext *hc,
+ const char *instance_name)
+{
+ struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
+ enum GNUNET_DB_QueryStatus qs;
+
+ qs = TALER_MERCHANTDB_update_instance_password_reset (
+ TMH_db,
+ instance_name,
+ now,
+ GNUNET_TIME_absolute_add (now,
+ PASSWORD_RESET_DELAY));
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ GNUNET_break (0);
+ return (MHD_NO ==
+ TALER_MHD_reply_with_error (hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "update_instance_password_reset"))
+ ? GNUNET_SYSERR
+ : GNUNET_NO;
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ GNUNET_break (0);
+ return (MHD_NO ==
+ TALER_MHD_reply_with_error (hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_SOFT_FAILURE,
+ "update_instance_password_reset"))
+ ? GNUNET_SYSERR
+ : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Refusing password reset of instance `%s': the last one was started less than %s ago\n",
+ instance_name,
+ GNUNET_TIME_relative2s (PASSWORD_RESET_DELAY,
+ false));
+ return (MHD_NO ==
+ TALER_MHD_reply_with_error (hc->connection,
+ MHD_HTTP_TOO_MANY_REQUESTS,
+ TALER_EC_MERCHANT_TAN_TOO_EARLY,
+ GNUNET_TIME_relative2s (
+ PASSWORD_RESET_DELAY,
+ false)))
+ ? GNUNET_SYSERR
+ : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ return GNUNET_OK;
+}
+
+
+/**
* Check that a set of MFA challenges has been satisfied by the
* client for the request in @a hc.
*
* @param[in,out] hc handler context with the connection to the client
* @param instance_name instance name to use in the message to the customer
* @param op operation for which we should check challenges for
+ * @param password_reset true if @a op is an unauthenticated password
+ * reset of @a instance_name, see #password_reset_start()
* @param combi_and true to tell the client to solve all challenges (AND),
* false means that any of the challenges will do (OR)
- * @param ... pairs of channel and address, terminated by
+ * @param ap pairs of channel and address, terminated by
* #TALER_MERCHANT_MFA_CHANNEL_NONE
* @return #GNUNET_OK on success (challenges satisfied)
* #GNUNET_NO if an error message was returned to the client
* #GNUNET_SYSERR to just close the connection
*/
-enum GNUNET_GenericReturnValue
-TMH_mfa_challenges_do (
+static enum GNUNET_GenericReturnValue
+mfa_challenges_do (
struct TMH_HandlerContext *hc,
const char *instance_name,
enum TALER_MERCHANT_MFA_CriticalOperation op,
+ bool password_reset,
bool combi_and,
- ...)
+ va_list ap)
{
struct Challenge challenges[MAX_CHALLENGES];
const char *challenge_ids[MAX_CHALLENGES];
@@ -445,10 +525,6 @@ TMH_mfa_challenges_do (
enum GNUNET_GenericReturnValue ret;
{
- va_list ap;
-
- va_start (ap,
- combi_and);
num_challenges = 0;
while (num_challenges < MAX_CHALLENGES)
{
@@ -470,7 +546,6 @@ TMH_mfa_challenges_do (
challenges[num_challenges].solvable = true;
num_challenges++;
}
- va_end (ap);
}
if (0 == num_challenges)
@@ -558,6 +633,22 @@ TMH_mfa_challenges_do (
GNUNET_free (target_address);
}
+ if (password_reset)
+ {
+ bool start_new = false;
+
+ for (size_t i = 0; i < num_challenges; i++)
+ if (NULL == challenges[i].challenge_id)
+ start_new = true;
+ if (start_new)
+ {
+ ret = password_reset_start (hc,
+ instance_name);
+ if (GNUNET_OK != ret)
+ goto cleanup;
+ }
+ }
+
{
struct GNUNET_TIME_Absolute expiration_date
= GNUNET_TIME_relative_to_absolute (CHALLENGE_LIFETIME);
@@ -674,6 +765,54 @@ cleanup:
enum GNUNET_GenericReturnValue
+TMH_mfa_challenges_do (
+ struct TMH_HandlerContext *hc,
+ const char *instance_name,
+ enum TALER_MERCHANT_MFA_CriticalOperation op,
+ bool combi_and,
+ ...)
+{
+ va_list ap;
+ enum GNUNET_GenericReturnValue ret;
+
+ va_start (ap,
+ combi_and);
+ ret = mfa_challenges_do (hc,
+ instance_name,
+ op,
+ false,
+ combi_and,
+ ap);
+ va_end (ap);
+ return ret;
+}
+
+
+enum GNUNET_GenericReturnValue
+TMH_mfa_auth_challenges_do (
+ struct TMH_HandlerContext *hc,
+ const char *instance_name,
+ bool password_reset,
+ bool combi_and,
+ ...)
+{
+ va_list ap;
+ enum GNUNET_GenericReturnValue ret;
+
+ va_start (ap,
+ combi_and);
+ ret = mfa_challenges_do (hc,
+ instance_name,
+ TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION,
+ password_reset,
+ combi_and,
+ ap);
+ va_end (ap);
+ return ret;
+}
+
+
+enum GNUNET_GenericReturnValue
TMH_mfa_check_simple (
struct TMH_HandlerContext *hc,
enum TALER_MERCHANT_MFA_CriticalOperation op,
diff --git a/src/backend/taler-merchant-httpd_mfa.h b/src/backend/taler-merchant-httpd_mfa.h
@@ -72,6 +72,33 @@ TMH_mfa_challenges_do (
/**
+ * Like #TMH_mfa_challenges_do() for the operation
+ * #TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION.
+ *
+ * @param[in,out] hc handler context with the connection to the client
+ * @param instance_name instance name to use in the message to the customer
+ * @param password_reset true if this is an unauthenticated password
+ * reset (forgot-password): then new challenges are only started
+ * if the last password reset of @a instance_name was started at
+ * least 15 minutes ago, otherwise the client gets a 429
+ * @param combi_and true to tell the client to solve all challenges (AND),
+ * false means that any of the challenges will do (OR)
+ * @param ... pairs of channel and address, terminated by
+ * #TALER_MERCHANT_MFA_CHANNEL_NONE
+ * @return #GNUNET_OK on success (challenges satisfied)
+ * #GNUNET_NO if an error message was returned to the client
+ * #GNUNET_SYSERR to just close the connection
+ */
+enum GNUNET_GenericReturnValue
+TMH_mfa_auth_challenges_do (
+ struct TMH_HandlerContext *hc,
+ const char *instance_name,
+ bool password_reset,
+ bool combi_and,
+ ...);
+
+
+/**
* Check MFA for a simple operation that simply requires
* a single additional factor (if any are configured).
*
diff --git a/src/backend/taler-merchant-httpd_post-management-instances-INSTANCE-auth.c b/src/backend/taler-merchant-httpd_post-management-instances-INSTANCE-auth.c
@@ -98,6 +98,8 @@ reply_with_login_token (
* @param mfa_combi_and require all MFA channels in @a tcs instead of any one
* @param login_token_duration how long a login token returned after the
* update should remain valid; zero means do not create a token
+ * @param password_reset true if this is an unauthenticated password
+ * reset, which may only be started once per 15 minutes per instance
* @return MHD result code
*/
static enum MHD_Result
@@ -108,7 +110,8 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi,
bool require_old_password,
enum TEH_TanChannelSet tcs,
bool mfa_combi_and,
- struct GNUNET_TIME_Relative login_token_duration)
+ struct GNUNET_TIME_Relative login_token_duration,
+ bool password_reset)
{
struct TALER_MERCHANTDB_InstanceAuthSettings ias;
struct TALER_MERCHANTDB_LoginTokenP login_token;
@@ -240,33 +243,33 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi,
ret = GNUNET_OK;
break;
case TMH_TCS_SMS:
- ret = TMH_mfa_challenges_do (hc,
- mi->settings.id,
- TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION,
- mfa_combi_and,
- TALER_MERCHANT_MFA_CHANNEL_SMS,
- mi->settings.phone,
- TALER_MERCHANT_MFA_CHANNEL_NONE);
+ ret = TMH_mfa_auth_challenges_do (hc,
+ mi->settings.id,
+ password_reset,
+ mfa_combi_and,
+ TALER_MERCHANT_MFA_CHANNEL_SMS,
+ mi->settings.phone,
+ TALER_MERCHANT_MFA_CHANNEL_NONE);
break;
case TMH_TCS_EMAIL:
- ret = TMH_mfa_challenges_do (hc,
- mi->settings.id,
- TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION,
- mfa_combi_and,
- TALER_MERCHANT_MFA_CHANNEL_EMAIL,
- mi->settings.email,
- TALER_MERCHANT_MFA_CHANNEL_NONE);
+ ret = TMH_mfa_auth_challenges_do (hc,
+ mi->settings.id,
+ password_reset,
+ mfa_combi_and,
+ TALER_MERCHANT_MFA_CHANNEL_EMAIL,
+ mi->settings.email,
+ TALER_MERCHANT_MFA_CHANNEL_NONE);
break;
case TMH_TCS_EMAIL_AND_SMS:
- ret = TMH_mfa_challenges_do (hc,
- mi->settings.id,
- TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION,
- mfa_combi_and,
- TALER_MERCHANT_MFA_CHANNEL_EMAIL,
- mi->settings.email,
- TALER_MERCHANT_MFA_CHANNEL_SMS,
- mi->settings.phone,
- TALER_MERCHANT_MFA_CHANNEL_NONE);
+ ret = TMH_mfa_auth_challenges_do (hc,
+ mi->settings.id,
+ password_reset,
+ mfa_combi_and,
+ TALER_MERCHANT_MFA_CHANNEL_EMAIL,
+ mi->settings.email,
+ TALER_MERCHANT_MFA_CHANNEL_SMS,
+ mi->settings.phone,
+ TALER_MERCHANT_MFA_CHANNEL_NONE);
break;
}
if (GNUNET_OK != ret)
@@ -469,7 +472,8 @@ TMH_private_post_instances_ID_auth (const struct TMH_RequestHandler *rh,
? TEH_mandatory_tan_channels
: TMH_TCS_NONE,
false,
- GNUNET_TIME_UNIT_ZERO);
+ GNUNET_TIME_UNIT_ZERO,
+ false);
}
@@ -531,7 +535,8 @@ TMH_public_post_instances_ID_auth (const struct TMH_RequestHandler *rh,
false,
TEH_mandatory_tan_channels,
true,
- token_duration);
+ token_duration,
+ true);
}
@@ -560,7 +565,8 @@ TMH_private_post_instances_default_ID_auth (
false,
TMH_TCS_NONE,
false,
- GNUNET_TIME_UNIT_ZERO);
+ GNUNET_TIME_UNIT_ZERO,
+ false);
return ret;
}
diff --git a/src/backenddb/meson.build b/src/backenddb/meson.build
@@ -193,6 +193,7 @@ libtalermerchantdb = library(
'update_donau_instance_receipts_amount.c',
'update_instance.c',
'update_instance_auth.c',
+ 'update_instance_password_reset.c',
'update_otp_device.c',
'update_pending_webhook.c',
'update_product.c',
diff --git a/src/backenddb/sql-schema/merchant-0049.sql b/src/backenddb/sql-schema/merchant-0049.sql
@@ -37,4 +37,11 @@ UPDATE tan_challenges
WHERE retransmission_date = 0
AND confirmation_date IS NULL;
+ALTER TABLE merchant_instances
+ ADD COLUMN earliest_password_reset INT8 NOT NULL DEFAULT 0;
+
+COMMENT ON COLUMN merchant_instances.earliest_password_reset
+ IS 'Earliest time at which a password reset (forgot-password) may start new MFA challenges for this instance; limits how often the TAN can be guessed';
+
+
COMMIT;
diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c
@@ -111,6 +111,7 @@
#include "merchant-database/iterate_webhooks.h"
#include "merchant-database/get_exchange_wire_fee.h"
#include "merchant-database/get_instance_auth.h"
+#include "merchant-database/update_instance_password_reset.h"
#include "merchant-database/update_to_contract_terms_paid.h"
#include "merchant-database/update_to_contract_terms_wired.h"
#include "merchant-database/insert_refund_by_coin.h"
@@ -968,6 +969,54 @@ test_get_instance_auth_null (const struct InstanceData *instance)
/**
+ * Tests that a password reset of an instance can only be started
+ * again once the earliest time for the next one has passed.
+ *
+ * @param instance the instance to run the test against.
+ * @return 0 on success, 1 otherwise.
+ */
+static int
+test_update_instance_password_reset (const struct InstanceData *instance)
+{
+ struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
+ struct GNUNET_TIME_Absolute next
+ = GNUNET_TIME_absolute_add (now,
+ GNUNET_TIME_UNIT_MINUTES);
+
+ TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_update_instance_password_reset (
+ pg,
+ instance->instance.id,
+ now,
+ next),
+ "First password reset was refused\n");
+ TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS ==
+ TALER_MERCHANTDB_update_instance_password_reset (
+ pg,
+ instance->instance.id,
+ now,
+ next),
+ "Second password reset was not refused\n");
+ TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_update_instance_password_reset (
+ pg,
+ instance->instance.id,
+ next,
+ GNUNET_TIME_absolute_add (next,
+ GNUNET_TIME_UNIT_MINUTES)),
+ "Password reset after the delay was refused\n");
+ TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS ==
+ TALER_MERCHANTDB_update_instance_password_reset (
+ pg,
+ "no-such-instance",
+ now,
+ next),
+ "Password reset of unknown instance succeeded\n");
+ return 0;
+}
+
+
+/**
* Tests that storing a report always yields a fresh serial.
*
* 'merchant_reports' has no unique key other than the identity
@@ -1129,6 +1178,8 @@ run_test_instances (struct TestInstances_Closure *cls)
TEST_RET_ON_FAIL (test_insert_report_serial (&cls->instances[0]));
/* Test that a NULL authentication hash/salt is not an error */
TEST_RET_ON_FAIL (test_get_instance_auth_null (&cls->instances[0]));
+ /* Test the limit on starting password resets */
+ TEST_RET_ON_FAIL (test_update_instance_password_reset (&cls->instances[0]));
/* Test instance private key deletion */
TEST_RET_ON_FAIL (test_delete_instance_private_key (&cls->instances[0],
GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
diff --git a/src/backenddb/update_instance_password_reset.c b/src/backenddb/update_instance_password_reset.c
@@ -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/backenddb/update_instance_password_reset.c
+ * @brief Implementation of the update_instance_password_reset function for Postgres
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/update_instance_password_reset.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_update_instance_password_reset (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *merchant_id,
+ struct GNUNET_TIME_Absolute now,
+ struct GNUNET_TIME_Absolute next_reset)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (merchant_id),
+ GNUNET_PQ_query_param_absolute_time (&now),
+ GNUNET_PQ_query_param_absolute_time (&next_reset),
+ GNUNET_PQ_query_param_end
+ };
+
+ PREPARE (pg,
+ "update_instance_password_reset",
+ "UPDATE merchant.merchant_instances SET"
+ " earliest_password_reset=$3"
+ " WHERE merchant_id=$1"
+ " AND earliest_password_reset <= $2");
+ return GNUNET_PQ_eval_prepared_non_select (pg->conn,
+ "update_instance_password_reset",
+ params);
+}
diff --git a/src/include/merchant-database/all.h b/src/include/merchant-database/all.h
@@ -198,6 +198,7 @@
#include "merchant-database/update_donau_instance_receipts_amount.h"
#include "merchant-database/update_instance.h"
#include "merchant-database/update_instance_auth.h"
+#include "merchant-database/update_instance_password_reset.h"
#include "merchant-database/update_mfa_challenge.h"
#include "merchant-database/update_money_pot.h"
#include "merchant-database/update_otp_device.h"
diff --git a/src/include/merchant-database/update_instance_password_reset.h b/src/include/merchant-database/update_instance_password_reset.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/update_instance_password_reset.h
+ * @brief implementation of the update_instance_password_reset function for Postgres
+ */
+#ifndef MERCHANT_DATABASE_UPDATE_INSTANCE_PASSWORD_RESET_H
+#define MERCHANT_DATABASE_UPDATE_INSTANCE_PASSWORD_RESET_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+/**
+ * Start a password reset of an instance if the last one was started
+ * long enough ago: if the earliest time for the next password reset
+ * of @a merchant_id is not after @a now, set it to @a next_reset.
+ *
+ * @param pg database context
+ * @param merchant_id identity of the instance
+ * @param now current time
+ * @param next_reset earliest time at which the password reset after
+ * this one may be started
+ * @return #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the password reset
+ * may be started now,
+ * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if it is too early
+ * (or the instance does not exist)
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_update_instance_password_reset (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *merchant_id,
+ struct GNUNET_TIME_Absolute now,
+ struct GNUNET_TIME_Absolute next_reset);
+
+#endif