/*
  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.h"
#include "taler-merchant-httpd_mfa.h"


/**
 * How many challenges do we allow at most per request?
 */
#define MAX_CHALLENGES 9

/**
 * How long are challenges valid?
 */
#define CHALLENGE_LIFETIME GNUNET_TIME_UNIT_DAYS


enum GNUNET_GenericReturnValue
TMH_mfa_parse_challenge_id (struct TMH_HandlerContext *hc,
                            const char *challenge_id,
                            uint64_t *challenge_serial,
                            struct TALER_MERCHANT_MFA_BodyHash *h_body)
{
  const char *dash = strchr (challenge_id,
                             '-');
  unsigned long long ser;
  char min;

  if (NULL == dash)
  {
    GNUNET_break_op (0);
    return (MHD_NO ==
            TALER_MHD_reply_with_error (hc->connection,
                                        MHD_HTTP_BAD_REQUEST,
                                        TALER_EC_GENERIC_PARAMETER_MALFORMED,
                                        "'-' missing in challenge ID"))
      ? GNUNET_SYSERR
      : GNUNET_NO;
  }
  if ( (2 !=
        sscanf (challenge_id,
                "%llu%c%*s",
                &ser,
                &min)) ||
       ('-' != min) )
  {
    GNUNET_break_op (0);
    return (MHD_NO ==
            TALER_MHD_reply_with_error (hc->connection,
                                        MHD_HTTP_BAD_REQUEST,
                                        TALER_EC_GENERIC_PARAMETER_MALFORMED,
                                        "Invalid number for challenge ID"))
      ? GNUNET_SYSERR
      : GNUNET_NO;
  }
  if (GNUNET_OK !=
      GNUNET_STRINGS_string_to_data (dash + 1,
                                     strlen (dash + 1),
                                     h_body,
                                     sizeof (*h_body)))
  {
    GNUNET_break_op (0);
    return (MHD_NO ==
            TALER_MHD_reply_with_error (hc->connection,
                                        MHD_HTTP_BAD_REQUEST,
                                        TALER_EC_GENERIC_PARAMETER_MALFORMED,
                                        "Malformed challenge ID"))
      ? GNUNET_SYSERR
      : GNUNET_NO;
  }
  *challenge_serial = (uint64_t) ser;
  return GNUNET_OK;
}


/**
 * Check if the given authentication check was already completed.
 *
 * @param[in,out] hc handler context of the connection to authorize
 * @param op operation for which we are requiring authorization
 * @param challenge_id ID of the challenge to check if it is done
 * @param[out] solved set to true if the challenge was solved,
 *             set to false if @a challenge_id was not found
 * @param[out] channel TAN channel that was used,
 *             set to #TALER_MERCHANT_MFA_CHANNEL_NONE if @a challenge_id
 *             was not found
 * @param[out] target_address address which was validated,
 *             set to NULL if @a challenge_id was not found
 * @param[out] retry_counter how many attempts are left on the challenge
 * @return #GNUNET_OK on success (challenge found)
 *         #GNUNET_NO if an error message was returned to the client
 *         #GNUNET_SYSERR to just close the connection
 */
static enum GNUNET_GenericReturnValue
mfa_challenge_check (
  struct TMH_HandlerContext *hc,
  enum TALER_MERCHANT_MFA_CriticalOperation op,
  const char *challenge_id,
  bool *solved,
  enum TALER_MERCHANT_MFA_Channel *channel,
  char **target_address,
  uint32_t *retry_counter)
{
  uint64_t challenge_serial;
  struct TALER_MERCHANT_MFA_BodyHash h_body;
  struct TALER_MERCHANT_MFA_BodyHash x_h_body;
  struct TALER_MERCHANT_MFA_BodySalt salt;
  struct GNUNET_TIME_Absolute retransmission_date;
  enum TALER_MERCHANT_MFA_CriticalOperation xop;
  enum GNUNET_DB_QueryStatus qs;
  struct GNUNET_TIME_Absolute confirmation_date;
  enum GNUNET_GenericReturnValue ret;

  GNUNET_log (GNUNET_ERROR_TYPE_INFO,
              "Checking status of challenge %s\n",
              challenge_id);
  ret = TMH_mfa_parse_challenge_id (hc,
                                    challenge_id,
                                    &challenge_serial,
                                    &x_h_body);
  if (GNUNET_OK != ret)
    return ret;
  *target_address = NULL;
  *solved = false;
  *channel = TALER_MERCHANT_MFA_CHANNEL_NONE;
  *retry_counter = UINT_MAX;
  qs = TMH_db->lookup_mfa_challenge (TMH_db->cls,
                                     challenge_serial,
                                     &x_h_body,
                                     &salt,
                                     target_address,
                                     &xop,
                                     &confirmation_date,
                                     &retransmission_date,
                                     retry_counter,
                                     channel);
  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_COMMIT_FAILED,
                                        NULL))
      ? 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,
                                        NULL))
      ? GNUNET_SYSERR
      : GNUNET_NO;
  case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
    GNUNET_log (GNUNET_ERROR_TYPE_INFO,
                "Challenge %s not found\n",
                challenge_id);
    return GNUNET_OK;
  case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
    break;
  }

  if (xop != op)
  {
    GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
                "Challenge was for a different operation (%d!=%d)!\n",
                (int) op,
                (int) xop);
    *solved = false;
    return GNUNET_OK;
  }
  TALER_MERCHANT_mfa_body_hash (hc->request_body,
                                &salt,
                                &h_body);
  if (0 !=
      GNUNET_memcmp (&h_body,
                     &x_h_body))
  {
    GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
                "Challenge was for a different request body!\n");
    *solved = false;
    return GNUNET_OK;
  }
  *solved = (! GNUNET_TIME_absolute_is_future (confirmation_date));
  return GNUNET_OK;
}


/**
 * 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 channel TAN channel to try
 * @param expiration_date when should the challenge expire
 * @param required_address addresses to use for
 *        the respective challenge
 * @param[out] challenge_id set to the challenge ID, to be freed by
 *   the caller
 * @return #GNUNET_OK on success,
 *         #GNUNET_NO if an error message was returned to the client
 *         #GNUNET_SYSERR to just close the connection
 */
static enum GNUNET_GenericReturnValue
mfa_challenge_start (
  struct TMH_HandlerContext *hc,
  enum TALER_MERCHANT_MFA_CriticalOperation op,
  enum TALER_MERCHANT_MFA_Channel channel,
  struct GNUNET_TIME_Absolute expiration_date,
  const char *required_address,
  char **challenge_id)
{
  enum GNUNET_DB_QueryStatus qs;
  struct TALER_MERCHANT_MFA_BodySalt salt;
  struct TALER_MERCHANT_MFA_BodyHash h_body;
  uint64_t challenge_serial;
  char *code;

  GNUNET_CRYPTO_random_block (GNUNET_CRYPTO_QUALITY_NONCE,
                              &salt,
                              sizeof (salt));
  TALER_MERCHANT_mfa_body_hash (hc->request_body,
                                &salt,
                                &h_body);
  GNUNET_asprintf (&code,
                   "%llu",
                   (unsigned long long)
                   GNUNET_CRYPTO_random_u64 (GNUNET_CRYPTO_QUALITY_NONCE,
                                             1000 * 1000 * 100));
  qs = TMH_db->create_mfa_challenge (TMH_db->cls,
                                     op,
                                     &h_body,
                                     &salt,
                                     code,
                                     expiration_date,
                                     GNUNET_TIME_UNIT_ZERO_ABS,
                                     channel,
                                     required_address,
                                     &challenge_serial);
  GNUNET_free (code);
  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_COMMIT_FAILED,
                                        NULL))
      ? 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,
                                        NULL))
      ? GNUNET_SYSERR
      : GNUNET_NO;
  case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
    GNUNET_assert (0);
    break;
  case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
    break;
  }
  {
    char *h_body_s;

    h_body_s = GNUNET_STRINGS_data_to_string_alloc (&h_body,
                                                    sizeof (h_body));
    GNUNET_asprintf (challenge_id,
                     "%llu-%s",
                     (unsigned long long) challenge_serial,
                     h_body_s);
    GNUNET_free (h_body_s);
  }
  return GNUNET_OK;
}


/**
 * Internal book-keeping for #TMH_mfa_challenges_do().
 */
struct Challenge
{
  /**
   * Channel on which the challenge is transmitted.
   */
  enum TALER_MERCHANT_MFA_Channel channel;

  /**
   * Address to send the challenge to.
   */
  const char *required_address;

  /**
   * Internal challenge ID.
   */
  char *challenge_id;

  /**
   * True if the challenge was solved.
   */
  bool solved;

  /**
   * True if the challenge could still be solved.
   */
  bool solvable;

};


/**
 * Obtain hint about the @a target_address of type @a channel to
 * return to the client.
 *
 * @param channel type of challenge
 * @param target_address address we will sent the challenge to
 * @return hint for the user about the address
 */
static char *
get_hint (enum TALER_MERCHANT_MFA_Channel channel,
          const char *target_address)
{
  switch (channel)
  {
  case TALER_MERCHANT_MFA_CHANNEL_NONE:
    GNUNET_assert (0);
    return NULL;
  case TALER_MERCHANT_MFA_CHANNEL_SMS:
    {
      size_t slen = strlen (target_address);
      const char *end;

      if (slen > 4)
        end = &target_address[slen - 4];
      else
        end = &target_address[slen / 2];
      return GNUNET_strdup (end);
    }
  case TALER_MERCHANT_MFA_CHANNEL_EMAIL:
    {
      const char *at;
      size_t len;

      at = strchr (target_address,
                   '@');
      if (NULL == at)
        len = 0;
      else
        len = at - target_address;
      return GNUNET_strndup (target_address,
                             len);
    }
  case TALER_MERCHANT_MFA_CHANNEL_TOTP:
    GNUNET_break (0);
    return GNUNET_strdup ("TOTP is not implemented: #10327");
  }
  GNUNET_break (0);
  return NULL;
}


/**
 * 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 op operation for which we should check challenges for
 * @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_challenges_do (
  struct TMH_HandlerContext *hc,
  enum TALER_MERCHANT_MFA_CriticalOperation op,
  bool combi_and,
  ...)
{
  struct Challenge challenges[MAX_CHALLENGES];
  const char *challenge_ids[MAX_CHALLENGES];
  size_t num_challenges;
  char *challenge_ids_copy = NULL;
  size_t num_provided_challenges;
  enum GNUNET_GenericReturnValue ret;

  {
    va_list ap;

    va_start (ap,
              combi_and);
    for (num_challenges = 0;
         num_challenges < MAX_CHALLENGES;
         num_challenges++)
    {
      enum TALER_MERCHANT_MFA_Channel channel;
      const char *address;

      channel = va_arg (ap,
                        enum TALER_MERCHANT_MFA_Channel);
      if (TALER_MERCHANT_MFA_CHANNEL_NONE == channel)
        break;
      address = va_arg (ap,
                        const char *);
      GNUNET_assert (NULL != address);
      challenges[num_challenges].channel = channel;
      challenges[num_challenges].required_address = address;
      challenges[num_challenges].challenge_id = NULL;
      challenges[num_challenges].solved = false;
      challenges[num_challenges].solvable = true;
    }
    va_end (ap);
  }

  if (0 == num_challenges)
  {
    /* No challenges required. Strange... */
    return GNUNET_OK;
  }

  {
    const char *challenge_ids_header;

    challenge_ids_header
      = MHD_lookup_connection_value (hc->connection,
                                     MHD_HEADER_KIND,
                                     "Taler-Challenge-Ids");
    num_provided_challenges = 0;
    if (NULL != challenge_ids_header)
    {
      challenge_ids_copy = GNUNET_strdup (challenge_ids_header);

      for (char *token = strtok (challenge_ids_copy,
                                 ",");
           NULL != token;
           token = strtok (NULL,
                           ","))
      {
        if (num_provided_challenges >= MAX_CHALLENGES)
        {
          GNUNET_break_op (0);
          GNUNET_free (challenge_ids_copy);
          return (MHD_NO ==
                  TALER_MHD_reply_with_error (
                    hc->connection,
                    MHD_HTTP_BAD_REQUEST,
                    TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED,
                    "Taler-Challenge-Ids"))
          ? GNUNET_SYSERR
          : GNUNET_NO;
        }
        challenge_ids[num_provided_challenges] = token;
        num_provided_challenges++;
      }
    }
  }

  /* Check provided challenges against requirements */
  for (size_t i = 0; i < num_provided_challenges; i++)
  {
    bool solved;
    enum TALER_MERCHANT_MFA_Channel channel;
    char *target_address;
    uint32_t retry_counter;

    ret = mfa_challenge_check (hc,
                               op,
                               challenge_ids[i],
                               &solved,
                               &channel,
                               &target_address,
                               &retry_counter);
    if (GNUNET_OK != ret)
      goto cleanup;
    for (size_t j = 0; j < num_challenges; j++)
    {
      if ( (challenges[j].channel == channel) &&
           (NULL == challenges[j].challenge_id) &&
           (NULL != target_address /* just to be sure */) &&
           (0 == strcmp (target_address,
                         challenges[j].required_address) ) )
      {
        challenges[j].solved
          = solved;
        challenges[j].challenge_id
          = GNUNET_strdup (challenge_ids[i]);
        if ( (! solved) &&
             (0 == retry_counter) )
        {
          /* can't be solved anymore! */
          challenges[i].solvable = false;
        }
        break;
      }
    }
    GNUNET_free (target_address);
  }

  {
    struct GNUNET_TIME_Absolute expiration_date
      = GNUNET_TIME_relative_to_absolute (CHALLENGE_LIFETIME);

    /* Start new challenges for unsolved requirements */
    for (size_t i = 0; i < num_challenges; i++)
    {
      if (NULL == challenges[i].challenge_id)
      {
        GNUNET_assert (! challenges[i].solved);
        GNUNET_assert (challenges[i].solvable);
        ret = mfa_challenge_start (hc,
                                   op,
                                   challenges[i].channel,
                                   expiration_date,
                                   challenges[i].required_address,
                                   &challenges[i].challenge_id);
        if (GNUNET_OK != ret)
          goto cleanup;
      }
    }
  }

  {
    bool all_solved = true;
    bool any_solved = false;
    bool solvable = true;

    for (size_t i = 0; i < num_challenges; i++)
    {
      if (challenges[i].solved)
      {
        any_solved = true;
      }
      else
      {
        all_solved = false;
        if (combi_and &&
            (! challenges[i].solvable) )
          solvable = false;
      }
    }

    if ( (combi_and && all_solved) ||
         (! combi_and && any_solved) )
    {
      /* Authorization successful */
      ret = GNUNET_OK;
      goto cleanup;
    }
    if (! solvable)
    {
      ret = (MHD_NO ==
             TALER_MHD_reply_with_error (
               hc->connection,
               MHD_HTTP_FORBIDDEN,
               TALER_EC_MERCHANT_MFA_FORBIDDEN,
               GNUNET_TIME_relative2s (CHALLENGE_LIFETIME,
                                       false)))
        ? GNUNET_SYSERR
        : GNUNET_NO;
      goto cleanup;
    }
  }

  /* Return challenges to client */
  {
    json_t *jchallenges;

    jchallenges = json_array ();
    GNUNET_assert (NULL != jchallenges);
    for (size_t i = 0; i<num_challenges; i++)
    {
      const struct Challenge *c = &challenges[i];
      json_t *jc;
      char *hint;

      hint = get_hint (c->channel,
                       c->required_address);

      jc = GNUNET_JSON_PACK (
        GNUNET_JSON_pack_string ("tan_info",
                                 hint),
        GNUNET_JSON_pack_string ("tan_channel",
                                 TALER_MERCHANT_MFA_channel_to_string (
                                   c->channel)),
        GNUNET_JSON_pack_string ("challenge_id",
                                 c->challenge_id));
      GNUNET_free (hint);
      GNUNET_assert (0 ==
                     json_array_append_new (
                       jchallenges,
                       jc));
    }
    ret = (MHD_NO ==
           TALER_MHD_REPLY_JSON_PACK (
             hc->connection,
             MHD_HTTP_ACCEPTED,
             GNUNET_JSON_pack_bool ("combi_and",
                                    combi_and),
             GNUNET_JSON_pack_array_steal ("challenges",
                                           jchallenges)))
      ? GNUNET_SYSERR
      : GNUNET_NO;
  }

cleanup:
  for (size_t i = 0; i < num_challenges; i++)
    GNUNET_free (challenges[i].challenge_id);
  GNUNET_free (challenge_ids_copy);
  return ret;
}


enum GNUNET_GenericReturnValue
TMH_mfa_check_simple (
  struct TMH_HandlerContext *hc,
  enum TALER_MERCHANT_MFA_CriticalOperation op,
  struct TMH_MerchantInstance *mi)
{
  enum GNUNET_GenericReturnValue ret;
  bool have_sms = (NULL != mi->settings.phone) &&
                  (NULL != TMH_helper_sms) &&
                  (mi->settings.phone_validated);
  bool have_email = (NULL != mi->settings.email) &&
                    (NULL != TMH_helper_email) &&
                    (mi->settings.email_validated);

  /* Note: we check for 'validated' above, but in theory
     we could also use unvalidated for this operation.
     That's a policy-decision we may want to revise,
     but probably need to look at the global threat model to
     make sure alternative configurations are still sane. */
  if (have_email)
  {
    ret = TMH_mfa_challenges_do (hc,
                                 op,
                                 false,
                                 TALER_MERCHANT_MFA_CHANNEL_EMAIL,
                                 mi->settings.email,
                                 have_sms
                                 ? TALER_MERCHANT_MFA_CHANNEL_SMS
                                 : TALER_MERCHANT_MFA_CHANNEL_NONE,
                                 mi->settings.phone,
                                 TALER_MERCHANT_MFA_CHANNEL_NONE);
  }
  else if (have_sms)
  {
    ret = TMH_mfa_challenges_do (hc,
                                 op,
                                 false,
                                 TALER_MERCHANT_MFA_CHANNEL_SMS,
                                 mi->settings.phone,
                                 TALER_MERCHANT_MFA_CHANNEL_NONE);
  }
  else
  {
    GNUNET_log (GNUNET_ERROR_TYPE_INFO,
                "No MFA possible, skipping 2-FA\n");
    ret = GNUNET_OK;
  }
  return ret;
}
