merchant

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

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:
Asrc/backend/taler-merchant-httpd_mfa.c | 431+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_mfa.h | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_private-post-instances.c | 2+-
Msrc/backenddb/pg_update_mfa_challenge.c | 5++++-
Msrc/backenddb/pg_update_mfa_challenge.h | 2++
Msrc/include/taler_merchantdb_plugin.h | 2++
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);