commit 1e4f75624aaff4bad23466ed058090c19a900346
parent d70a8cbfbce6574b0a61f789962d49b5efb9891a
Author: Christian Grothoff <christian@grothoff.org>
Date: Mon, 1 Sep 2025 01:12:53 +0200
start with MFA internal API
Diffstat:
6 files changed, 500 insertions(+), 2 deletions(-)
diff --git a/src/backend/taler-merchant-httpd_mfa.c b/src/backend/taler-merchant-httpd_mfa.c
@@ -0,0 +1,431 @@
+/*
+ This file is part of TALER
+ (C) 2025 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 taler-merchant-httpd_mfa.c
+ * @brief internal APIs for multi-factor authentication (MFA)
+ * @author Christian Grothoff
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_mfa.h"
+
+/**
+ * How many attempts do we allow per solution at most? Note that
+ * this is just for the API, the value must also match the
+ * database logic in create_mfa_challenge.
+ */
+#define MAX_SOLUTIONS 3
+
+
+/**
+ * How long is an OTP code valid?
+ */
+#define OTP_TIMEOUT GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_SECONDS, 30)
+
+
+/**
+ * Setup challenge @a code for @a channel and send it to the
+ * @a required_address; on success, set @a expiration_date
+ * and @a retransmission_date based on the @a channel. Return
+ * a @a hint for the user.
+ *
+ * @param channel how to send the challenge
+ * @param required_address where to send the challenge
+ * @param[out] code set to challenge code
+ * @param[out] expiration_date when the challenge expires
+ * @param[out] retransmission_date when we may send another challenge
+ * to the same @a required_address
+ * @param[out] hint set to a hint to return to the user
+ * about where he will receive the challenge
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_DB_QueryStatus
+setup_challenge (enum TALER_MERCHANT_MFA_Channel channel,
+ const char *required_address,
+ char **code,
+ struct GNUNET_TIME_Absolute *expiration_date,
+ struct GNUNET_TIME_Absolute *retransmission_date,
+ char **hint)
+{
+ const char *prog;
+
+ switch (channel)
+ {
+ case TALER_MERCHANT_MFA_CHANNEL_NONE:
+ GNUNET_assert (0);
+ break;
+ case TALER_MERCHANT_MFA_CHANNEL_SMS:
+ *expiration_date
+ = GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS);
+ *retransmission_date
+ = GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS);
+ GNUNET_asprintf (code,
+ "%llu",
+ (unsigned long long)
+ GNUNET_CRYPTO_random_u64 (GNUNET_CRYPTO_QUALITY_NONCE,
+ 100000000));
+ prog = "taler-merchant-helper-send-sms";
+ *hint = NULL; // FIXME: generate nice hint!
+ break;
+ case TALER_MERCHANT_MFA_CHANNEL_EMAIL:
+ *expiration_date
+ = GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS);
+ *retransmission_date
+ = GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS);
+ GNUNET_asprintf (code,
+ "%llu",
+ (unsigned long long)
+ GNUNET_CRYPTO_random_u64 (GNUNET_CRYPTO_QUALITY_NONCE,
+ 100000000));
+ prog = "taler-merchant-helper-send-email";
+ *hint = NULL; // FIXME: generate nice hint!
+ break;
+ case TALER_MERCHANT_MFA_CHANNEL_TOTP:
+ *expiration_date
+ = GNUNET_TIME_relative_to_absolute (OTP_TIMEOUT);
+ *retransmission_date
+ = GNUNET_TIME_relative_to_absolute (OTP_TIMEOUT);
+ GNUNET_asprintf (hint,
+ "You have %s to submit the correct OTP code",
+ GNUNET_TIME_relative_to_string (OTP_TIMEOUT,
+ false));
+ code = GNUNET_strdup ("not implemented");
+ return GNUNET_OK;
+ }
+
+ // run 'prog' with 'code'!
+ GNUNET_break (0); // not implemented
+ return GNUNET_SYSERR;
+}
+
+
+/**
+ * If the @a retransmit_date is in the past, send a fresh challenge.
+ *
+ * @param[in,out] hc handler context of the connection to authorize
+ * @param op operation for which we are performing
+ * @param challenge_id challenge to update
+ * @param channel how to send the challenge
+ * @param required_address where to send the challenge
+ * @param retransmit_date when to send the challenge at the earliest
+ * @param[out] hint hint to return to the user
+ * @return #GNUNET_OK on success (including if @a retransmit_date is in the
+ * future)
+ * #GNUNET_NO if we failed to send a challenge and queued an
+ * error response
+ * #GNUNET_SYSERR if the client connection must simply be closed
+ */
+static enum GNUNET_DB_QueryStatus
+retransmit_challenge (
+ struct TMH_HandlerContext *hc,
+ uint64_t challenge_id,
+ enum TALER_MERCHANT_MFA_Channel channel,
+ const char *required_address,
+ struct GNUNET_TIME_Absolute retransmit_date,
+ char **hint)
+{
+ enum GNUNET_DB_QueryStatus res;
+ char *code;
+
+ *hint = NULL;
+ if (GNUNET_TIME_absolute_is_future (retransmit_date))
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Challenge already sent to `%s' recently, not sending again\n",
+ required_address);
+ GNUNET_asprintf (
+ hint,
+ "Challenge will not be retransmitted before %s",
+ GNUNET_STRINGS_absolute_time_to_string (retransmit_date));
+ return GNUNET_OK;
+ }
+ res = setup_challenge (channel,
+ required_address,
+ &code,
+ &expiration_date,
+ &retransmit_date,
+ hint);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break (0);
+ return res;
+ }
+
+ qs = TMH_db->update_mfa_challenge (TMH_db->cls,
+ challenge_id,
+ code,
+ MAX_SOLUTIONS,
+ expiration_date,
+ retransmit_date);
+ GNUNET_free (code);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ (GNUNET_DB_STATUS_SOFT_ERROR == qs)
+ ? TALER_EC_GENERIC_DB_SOFT_FAILURE
+ : TALER_EC_GENERIC_DB_COMMIT_FAILED,
+ NULL);
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_INVARIANT_FAILURE,
+ "no results on INSERT, but success?");
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ return GNUNET_OK;
+
+}
+
+
+/**
+ * Create a challenge in the backend and transmit it to
+ * the @a required_address.
+ *
+ * @param[in,out] hc handler context of the connection to authorize
+ * @param op operation for which we are performing
+ * @param h_body salted hash of the request body
+ * @param salt salt used for @a h_body
+ * @param channel how to send the challenge
+ * @param required_address where to send the challenge
+ * @param[out] challenge_id set to the numeric ID of the
+ * challenge
+ * @param[out] hint set to a hint to return to the user
+ * about where he will receive the challenge
+ */
+static enum GNUNET_DB_QueryStatus
+create_challenge (
+ struct TMH_HandlerContext *hc,
+ enum TALER_MERCHANT_MFA_CriticalOperation op,
+ const struct TALER_MERCHANT_MFA_BodyHash *h_body,
+ const struct TALER_MERCHANT_MFA_BodySalt *salt,
+ enum TALER_MERCHANT_MFA_Channel channel,
+ const char *required_address,
+ uint64_t *challenge_id,
+ char **hint)
+{
+ enum GNUNET_DB_QueryStatus qs;
+ char *code;
+ struct GNUNET_TIME_Absolute expiration_date;
+ struct GNUNET_TIME_Absolute retransmission_date;
+ enum GNUNET_DB_QueryStatus res;
+
+ *hint = NULL;
+ res = setup_challenge (channel,
+ required_address,
+ &code,
+ &expiration_date,
+ &retransmission_date,
+ hint);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break (0);
+ return res;
+ }
+ qs = TMH_db->create_mfa_challenge (TMH_db->cls,
+ hc->instance->settings.id,
+ op,
+ h_body,
+ salt,
+ code,
+ expiration_date,
+ retransmission_date,
+ channel,
+ *hint,
+ challenge_id);
+ GNUNET_free (code);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ (GNUNET_DB_STATUS_SOFT_ERROR == qs)
+ ? TALER_EC_GENERIC_DB_SOFT_FAILURE
+ : TALER_EC_GENERIC_DB_COMMIT_FAILED,
+ NULL);
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_INVARIANT_FAILURE,
+ "no results on INSERT, but success?");
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ return GNUNET_OK;
+}
+
+
+enum GNUNET_DB_QueryStatus
+TMH_mfa_challenge_check (
+ struct TMH_HandlerContext *hc,
+ enum TALER_MERCHANT_MFA_CriticalOperation op,
+ enum TALER_MERCHANT_MFA_Channel required_tans[],
+ const char *required_addresses[])
+{
+ enum TALER_MERCHANT_MFA_Channel last_ch;
+ enum GNUNET_DB_QueryStatus qs;
+ struct TALER_MERCHANT_MFA_BodyHash h_body;
+ struct TALER_MERCHANT_MFA_BodySalt salt;
+ struct GNUNET_TIME_Absolute confirmation_date;
+ struct GNUNET_TIME_Absolute retransmission_date;
+ uint32_t retry_counter;
+ uint64_t challenge_id;
+ MHD_RESULT ret;
+ enum GNUNET_DB_QueryStatus res;
+ char *hint = NULL;
+
+ if (TALER_MERCHANT_MFA_CHANNEL_NONE == required_trans[0])
+ return GNUNET_OK;
+ qs = TMH_db->lookup_mfa_challenge (TMH_db->cls,
+ hc->instance->settings.id,
+ op,
+ hc->request_body,
+ &h_body,
+ &salt,
+ &conf_date,
+ &retransmit_date,
+ &retry_counter,
+ &last_channel,
+ &challenge_id);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ (GNUNET_DB_STATUS_SOFT_ERROR == qs)
+ ? TALER_EC_GENERIC_DB_SOFT_FAILURE
+ : TALER_EC_GENERIC_DB_COMMIT_FAILED,
+ NULL);
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_CRYPTO_random_block (GNUNET_CRYPTO_QUALITY_NONCE,
+ &salt,
+ sizeof (salt));
+ TALER_MERCHANT_mfa_body_hash (hc->request_body,
+ &salt,
+ &h_body);
+ next_challenge = required_trans[0];
+ retransmission_date = GNUNET_TIME_UNIT_ZERO_ABS; /* now */
+ retry_counter = MAX_SOLUTIONS;
+ res = create_challenge (hc,
+ op,
+ &h_body,
+ &salt,
+ next_challenge,
+ required_addresses[0],
+ &challenge_id,
+ &hint);
+ if (GNUNET_OK != res)
+ return res;
+ break;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ {
+ bool found = false;
+
+ for (unsigned int i=0;
+ TALER_MERCHANT_MFA_CHANNEL_NONE != required_trans[i];
+ i++)
+ {
+ if (last_channel == required_trans[i])
+ {
+ if (GNUNET_TIME_absolute_is_past (confirmation_date))
+ {
+ /* Last challenge WAS solved, move on! */
+ next_challenge = required_trans[i + 1];
+ retransmission_date = GNUNET_TIME_UNIT_ZERO_ABS; /* now */
+ retry_counter = MAX_SOLUTIONS;
+ if (TALER_MERCHANT_MFA_CHANNEL_NONE == next_challenge)
+ return GNUNET_OK;
+ res = create_challenge (hc,
+ op,
+ &h_body,
+ &salt,
+ next_challenge,
+ required_addresses[i + 1],
+ &challenge_id,
+ &hint);
+ if (GNUNET_OK != res)
+ return res;
+ }
+ else
+ {
+ /* challenge was not yet solved, possibly re-transmit
+ a new one */
+ next_challenge = last_channel;
+ res = retransmit_challenge (hc,
+ challenge_id,
+ last_channel,
+ required_addresses[i],
+ retransmit_date,
+ &hint);
+ if (GNUNET_OK != res)
+ return res;
+ }
+ found = true;
+ break;
+ }
+ }
+ if (! found)
+ {
+ /* challenge was satisfied that is not in our list! */
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_INVARIANT_FAILURE,
+ "unexpected challenge already satisfied");
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ }
+ }
+ break;
+ }
+
+ /* Return HTTP response that a challenge was sent */
+ ret = TALER_MHD_REPLY_JSON_PACK (
+ hc->connection,
+ MHD_HTTP_FORBIDDEN, /* FIXME: check HTTP status code! Maybe use ACCEPTED? */
+ // FIXME: add ec!?
+ GNUNET_JSON_pack_string ("hint",
+ hint),
+ GNUNET_JSON_pack_uint64 ("challenge_id",
+ challenge_id),
+ GNUNET_JSON_pack_auto_from_type ("h_body",
+ &h_body));
+ GNUNET_free (hint);
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+}
diff --git a/src/backend/taler-merchant-httpd_mfa.h b/src/backend/taler-merchant-httpd_mfa.h
@@ -0,0 +1,60 @@
+/*
+ This file is part of TALER
+ (C) 2025 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 taler-merchant-httpd_mfa.h
+ * @brief internal APIs for multi-factor authentication (MFA)
+ * @author Christian Grothoff
+ */
+#ifndef TALER_MERCHANT_HTTPD_MFA_H
+#define TALER_MERCHANT_HTTPD_MFA_H
+
+#include "taler-merchant-httpd.h"
+#include "taler_merchant_util.h"
+
+
+/**
+ * Multi-factor authentication check to see if for the given @a instance_id
+ * and the @a op operation all the TAN channels given in @a required_tans have
+ * been satisfied. Note that we always satisfy @a required_tans in the order
+ * given in the array, so if the last one is satisfied, all previous ones must
+ * have been satisfied before.
+ *
+ * If the challenges has not been satisfied, an appropriate response
+ * is returned to the client of @a hc.
+ *
+ * @param[in,out] hc handler context of the connection to authorize
+ * @param op operation for which we are performing
+ * @param required_tans array of TAN channels to try,
+ * terminated with #TALER_MERCHANT_MFA_CHANNEL_NONE
+ * @param required_addresses array of addresses to use for
+ * the respective challenge, NULL-terminated, same length
+ * as @a required_tans
+ * @return #GNUNET_OK if all challenges have been satisfied
+ * #GNUNET_NO if a challenge response was returned to the client
+ * #GNUNET_SYSERR to just close the connection without response
+ */
+enum GNUNET_DB_QueryStatus
+TMH_mfa_challenge_check (
+ struct TMH_HandlerContext *hc,
+ enum TALER_MERCHANT_MFA_CriticalOperation op,
+ enum TALER_MERCHANT_MFA_Channel required_tans[],
+ const char *required_addresses[]);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_private-post-instances.c b/src/backend/taler-merchant-httpd_private-post-instances.c
@@ -1,6 +1,6 @@
/*
This file is part of TALER
- (C) 2020-2023 Taler Systems SA
+ (C) 2020-2025 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
diff --git a/src/backenddb/pg_update_mfa_challenge.c b/src/backenddb/pg_update_mfa_challenge.c
@@ -32,6 +32,7 @@ TMH_PG_update_mfa_challenge (
uint64_t challenge_id,
const char *code,
uint32_t retry_counter,
+ struct GNUNET_TIME_Absolute expiration_date,
struct GNUNET_TIME_Absolute retransmission_date)
{
struct PostgresClosure *pg = cls;
@@ -39,6 +40,7 @@ TMH_PG_update_mfa_challenge (
GNUNET_PQ_query_param_uint64 (&challenge_id),
GNUNET_PQ_query_param_string (code),
GNUNET_PQ_query_param_uint32 (&retry_counter),
+ GNUNET_PQ_query_param_absolute_time (&expiration_date),
GNUNET_PQ_query_param_absolute_time (&retransmission_date),
GNUNET_PQ_query_param_end
};
@@ -49,7 +51,8 @@ TMH_PG_update_mfa_challenge (
" SET"
" code=$2"
" ,retry_counter=$3"
- " ,retransmission_date=$4"
+ " ,expiration_date=$4"
+ " ,retransmission_date=$5"
" WHERE challenge_id = $1;");
return GNUNET_PQ_eval_prepared_non_select (pg->conn,
"update_mfa_challenge",
diff --git a/src/backenddb/pg_update_mfa_challenge.h b/src/backenddb/pg_update_mfa_challenge.h
@@ -35,6 +35,7 @@
* @param code new challenge code
* @param retry_counter number of attempts that remain
* for solving the challenge
+ * @param expiration_date when should the challenge expire
* @param retransmission_date set to when a fresh challenge
* may be transmitted next
* @return database result code
@@ -45,6 +46,7 @@ TMH_PG_update_mfa_challenge (
uint64_t challenge_id,
const char *code,
uint32_t retry_counter,
+ struct GNUNET_TIME_Absolute expiration_date,
struct GNUNET_TIME_Absolute retransmission_date);
diff --git a/src/include/taler_merchantdb_plugin.h b/src/include/taler_merchantdb_plugin.h
@@ -4246,6 +4246,7 @@ struct TALER_MERCHANTDB_Plugin
* @param code new challenge code
* @param retry_counter number of attempts that remain
* for solving the challenge
+ * @param expiration_date when should the challenge expire
* @param retransmission_date set to when a fresh challenge
* may be transmitted next
* @return database result code
@@ -4256,6 +4257,7 @@ struct TALER_MERCHANTDB_Plugin
uint64_t challenge_id,
const char *code,
uint32_t retry_counter,
+ struct GNUNET_TIME_Absolute expiration_date,
struct GNUNET_TIME_Absolute retransmission_date);