/*
  This file is part of GNU Taler
  Copyright (C) 2026 Taler Systems SA

  GNU 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.

  GNU 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 GNU Taler; see the file COPYING.  If not,
  write to the Free Software Foundation, Inc., 51 Franklin
  Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

/**
 * @author Christian Grothoff
 * @file src/backend/paivana-httpd_cookie.c
 * @brief Cookie computation logic for paivana
 */
#include "platform.h"
#include <curl/curl.h>
#include <gcrypt.h>
#include <gnunet/gnunet_util_lib.h>
#include <taler/taler_mhd_lib.h>
#include "paivana-httpd_cookie.h"
#include "paivana-httpd.h"


/**
 * Secret for the cookie generation.
 */
struct GNUNET_HashCode paivana_secret;


/**
 * Reduce @a expiration to the granularity the cookie is able to
 * carry.
 *
 * The `Set-Cookie` value transmits the expiration in seconds while
 * the MAC covers it in microseconds, so the two agree only if the MAC
 * is taken over the microsecond value those transmitted seconds decode
 * back to.  For a `struct GNUNET_TIME_Timestamp` proper -- "a round
 * number of seconds in microseconds", gnunet_time_lib.h -- that is the
 * value itself, but nothing enforces the contract at this boundary:
 * #GNUNET_TIME_UNIT_FOREVER_TS is UINT64_MAX microseconds, which is
 * not a round number of seconds, and reaches us straight from the
 * client as `{"expiration":{"t_s":"never"}}'.  Deriving both the wire
 * value and the MAC input from this one function makes the round trip
 * an identity instead of an invariant every caller has to know about;
 * a cookie minted for such an expiration used to verify against a
 * different digest than the one it was signed with, so the client paid
 * and received a cookie that could never grant it anything.
 *
 * @param expiration end of the access being granted
 * @return @a expiration in seconds since the epoch
 */
static uint64_t
cookie_expiration_s (struct GNUNET_TIME_Timestamp expiration)
{
  return expiration.abs_time.abs_value_us
         / GNUNET_TIME_UNIT_SECONDS.rel_value_us;
}


/**
 * Compute access cookie hash for the given @a expiration, the
 * @a website and @a ca.
 *
 * @param expiration time at which the access being granted ends
 * @param website URL the cookie is valid for
 * @param ca_len number of bytes in @a ca
 * @param ca client (IP) address
 * @param[out] c set to the cookie hash
 */
static void
compute_cookie_hash (struct GNUNET_TIME_Timestamp expiration,
                     const char *website,
                     size_t ca_len,
                     const void *ca,
                     struct GNUNET_HashCode *c)
{
  struct GNUNET_TIME_AbsoluteNBO e;

  if (NULL == ca)
  {
    /* The client address is optional at the #PAIVANA_HTTPD_check_cookie()
       call site, which falls back to a zero-length address when MHD
       cannot tell it one.  Both memcpy() and gcry_md_write() are
       undefined on a NULL pointer even for a zero length, so hand them
       something to point at; the digest sees the same nothing. */
    GNUNET_assert (0 == ca_len);
    ca = "";
  }
  GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
              "Computing cookie for %s expiring at %llu and client %s\n",
              website,
              (unsigned long long) cookie_expiration_s (expiration),
              TALER_b2s (ca,
                         ca_len));
  e = GNUNET_TIME_absolute_hton (
    GNUNET_TIME_absolute_from_s (cookie_expiration_s (expiration)));
  if (PH_global_cookie)
    website = "";
  GNUNET_assert (GNUNET_YES ==
                 GNUNET_CRYPTO_hkdf_gnunet (
                   c,            /* result */
                   sizeof (*c),
                   &e,          /* salt */
                   sizeof (e),
                   &paivana_secret, /* source key material */
                   sizeof (paivana_secret),
                   GNUNET_CRYPTO_kdf_arg (website,
                                          strlen (website) + 1),
                   GNUNET_CRYPTO_kdf_arg (ca,
                                          ca_len)));
}


bool
PAIVANA_HTTPD_check_cookie (const char *cookie,
                            const char *website,
                            size_t ca_len,
                            const void *ca)
{
  const char *dash;
  char *endptr;
  unsigned long long u;
  struct GNUNET_HashCode h;
  struct GNUNET_HashCode c;
  struct GNUNET_TIME_Timestamp a;

  dash = strchr (cookie,
                 '-');
  if (NULL == dash)
  {
    GNUNET_break_op (0);
    return false;
  }
  /* The seconds are a bare decimal count.  strtoull() on its own would
     also take leading whitespace, a '+' or '-' sign and leading zeros
     -- none of which we ever emit, and each of which is a second
     spelling of a cookie we already handed out -- so the first
     character is checked here rather than left to it.  What verifies
     the literal '-' is the comparison against @a dash: it is the one
     thing a "%llu-" format string looks like it establishes and does
     not. */
  if ( (! isdigit ((unsigned char) cookie[0])) ||
       ( ('0' == cookie[0]) &&
         (cookie + 1 != dash) ) )
  {
    GNUNET_break_op (0);
    return false;
  }
  errno = 0;
  u = strtoull (cookie,
                &endptr,
                10);
  if ( (0 != errno) ||
       (endptr != dash) )
  {
    GNUNET_break_op (0);
    return false;
  }
  dash++;
  if (GNUNET_OK !=
      GNUNET_STRINGS_string_to_data (dash,
                                     strlen (dash),
                                     &c,
                                     sizeof (c)))
  {
    GNUNET_break_op (0);
    return false;
  }
  a.abs_time = GNUNET_TIME_absolute_from_s (u);
  compute_cookie_hash (a,
                       website,
                       ca_len,
                       ca,
                       &h);
  if (0 !=
      GNUNET_memcmp_priv (&c,
                          &h))
  {
    GNUNET_log (GNUNET_ERROR_TYPE_INFO,
                "Cookie hash does not match!\n");
    return false;
  }
  /* The expiration is examined only now that the value is known to be
     one of ours.  Testing it first was not a weakness -- it is the HKDF
     salt, so a tampered expiration fails the MAC rather than being
     believed -- but it acted on, and logged, a field no one had
     authenticated yet, which let any client write a timestamp of its
     choosing into the log by presenting a made-up cookie.  The price is
     one HKDF on a value that was going to be rejected anyway. */
  if (GNUNET_TIME_absolute_is_past (a.abs_time))
  {
    GNUNET_log (GNUNET_ERROR_TYPE_INFO,
                "Cookie expired %s ago\n",
                GNUNET_TIME_relative2s (
                  GNUNET_TIME_absolute_get_duration (a.abs_time),
                  true));
    return false;
  }
  return true;
}


/**
 * Is @a c a character a cookie `Path` attribute may carry literally?
 *
 * The value has to satisfy two grammars at once.  RFC 3986 section 3.3
 * gives what a URI path may spell out:
 *   pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
 * and RFC 6265 section 4.1.1 gives what the attribute may contain:
 *   path-value = <any CHAR except CTLs or ";">
 * so the literal set is pchar without ';', plus the '/' that separates
 * segments.  Everything else is what a browser percent-encodes in the
 * request-URI, and hence what RFC 6265 section 5.1.4 path-match will
 * compare against.
 *
 * @param c character to classify
 * @return true if @a c may be emitted as-is
 */
static bool
path_char_literal_ok (unsigned char c)
{
  if ( ( ('a' <= c) && ('z' >= c) ) ||
       ( ('A' <= c) && ('Z' >= c) ) ||
       ( ('0' <= c) && ('9' >= c) ) )
    return true;                        /* unreserved, RFC 3986 sec 2.3 */
  if ('\0' == c)
    return false;
  return NULL != strchr ("-._~"         /* unreserved, RFC 3986 sec 2.3 */
                         "!$&'()*+,="   /* sub-delims minus ';', sec 2.2 */
                         ":@"           /* the rest of pchar, sec 3.3 */
                         "/",           /* segment separator, sec 3.3 */
                         c);
}


/**
 * Render @a path as an RFC 6265 section 4.1.1 `path-value` that
 * path-matches the requests a browser will actually make for it.
 *
 * The path we are handed has been through MHD, which percent-decodes
 * the request URI before the handler sees it, and then through the
 * client, which echoes it back to the payment endpoint.  The browser,
 * however, matches the stored cookie-path against the *request* path
 * (RFC 6265 sections 5.1.4 and 5.4), which is the encoded one -- so a
 * decoded path is emitted only to never match again.  Re-encode
 * everything outside the literal set, and pass an existing
 * percent-triplet through unchanged so that an already-encoded path
 * does not get encoded twice.  A literal '%' that happens to be
 * followed by two hex digits is indistinguishable from a triplet here;
 * that ambiguity is inherent to having been decoded once already.
 *
 * @param path path component of the website URL, starting at its '/'
 * @return the attribute value, or NULL if @a path cannot be expressed
 *         as one; the caller then has to fall back to "/"
 */
static char *
encode_path (const char *path)
{
  static const char hex[] = "0123456789ABCDEF";
  size_t len;
  size_t off = 0;
  char *res;

  if ('/' != path[0])
  {
    /* RFC 6265 section 5.2.4: a Path that does not begin with '/' is
       discarded by the user agent in favour of the default-path. */
    GNUNET_break (0);
    return NULL;
  }
  /* RFC 3986 section 3.3: the path ends at the first '?' or '#'; a
     query or a fragment is not part of it.  The request-path of
     RFC 6265 section 5.1.4 is likewise taken "without the %x3F ('?')
     character or query string", so leaving one in yields a cookie-path
     nothing can ever path-match. */
  len = strcspn (path,
                 "?#");
  res = GNUNET_malloc (3 * len + 1);
  for (size_t i = 0; i<len; i++)
  {
    unsigned char c = (unsigned char) path[i];

    if (';' == c)
    {
      /* RFC 6265 section 4.1.1 excludes ';' from path-value outright
         (it would start the next cookie-av), while RFC 3986 section 3.3
         allows it in a path as a sub-delim.  Percent-encoding it would
         satisfy the grammar but no longer path-match the request, so
         such a path simply cannot be expressed. */
      GNUNET_free (res);
      return NULL;
    }
    if ( ('%' == c) &&
         (i + 2 < len) &&
         (isxdigit ((unsigned char) path[i + 1])) &&
         (isxdigit ((unsigned char) path[i + 2])) )
    {
      res[off++] = path[i];
      res[off++] = path[i + 1];
      res[off++] = path[i + 2];
      i += 2;
      continue;
    }
    if (path_char_literal_ok (c))
    {
      res[off++] = (char) c;
      continue;
    }
    res[off++] = '%';
    res[off++] = hex[c >> 4];
    res[off++] = hex[c & 15];
  }
  res[off] = '\0';
  return res;
}


char *
PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp expiration,
                              const char *website,
                              size_t ca_len,
                              const void *ca)
{
  struct GNUNET_HashCode h;
  char *end;
  char cstr[128];
  char *res;
  char *epath = NULL;
  const char *url = "/";
  /* RFC 6265 section 4.1.2.5: `Secure` says the credential must never
     leave the user agent over an unsecured channel.  Deciding that from
     the website URL alone would let whoever picked that URL decide it:
     for an order carrying a fulfillment_url the string comes from the
     order, and the pay endpoint only checks it against the contract.
     BASE_URL is the operator's own statement about the scheme clients
     reach us with -- including the usual deployment where a reverse
     proxy terminates the TLS and we ourselves only ever see plaintext
     -- so it wins wherever it is configured, and the website URL is
     consulted only when it is not.  Note that the transport itself
     answers a different question: paivana never passes MHD_USE_TLS
     (see PAIVANA_HTTPD_serve_requests()), so
     #MHD_CONNECTION_INFO_PROTOCOL says "plaintext" in every deployment
     that exists today, and taking it as the sole source would drop
     `Secure` from every cookie we hand out. */
  bool use_https = (0 ==
                    strncasecmp ((NULL != PH_base_url)
                                 ? PH_base_url
                                 : website,
                                 "https://",
                                 strlen ("https://")));
  struct GNUNET_TIME_Relative duration
    = GNUNET_TIME_absolute_get_remaining (expiration.abs_time);
  /* RFC 6265 section 5.2.2: a non-positive Max-Age tells the user agent
     to expire the cookie immediately.  Truncating to seconds turns any
     lifetime below one second into exactly that, so the client would
     pay and have the cookie deleted on arrival; give it the one second
     the wire format is able to express instead. */
  unsigned long long max_age
    = GNUNET_MAX (1LLU,
                  (unsigned long long) (duration.rel_value_us
                                        / GNUNET_TIME_UNIT_SECONDS.
                                        rel_value_us));

  if (! PH_global_cookie)
  {
    const char *dslash = strstr (website,
                                 "//");
    const char *path = NULL;

    if (NULL != dslash)
      path = strchr (dslash + 2,
                     '/');
    if (NULL != path)
    {
      epath = encode_path (path);
      if (NULL != epath)
        url = epath;
      else
        GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
                    "Path of `%s' is not expressible as a cookie Path"
                    " attribute; scoping the access cookie to `/'\n",
                    website);
    }
  }
  compute_cookie_hash (expiration,
                       website,
                       ca_len,
                       ca,
                       &h);
  end = GNUNET_STRINGS_data_to_string (&h,
                                       sizeof (h),
                                       cstr,
                                       sizeof (cstr));
  GNUNET_assert (NULL != end);
  *end = '\0';
  GNUNET_asprintf (
    &res,
    /* RFC 6265 section 4.1.1:
         set-cookie-string = cookie-pair *( ";" SP cookie-av )
       -- every ';' is followed by SP and an attribute, so the value
       must not end on one.

       `SameSite=Lax' is what browsers already default to (RFC 6265bis
       section 5.4.7), stated rather than inherited: this cookie is a
       bearer credential for paid access, and a default is not the place
       to leave that.  Lax and not Strict, because the access it grants
       is reached by top-level navigation -- the wallet sends the client
       back to the article it just paid for -- and Strict would withhold
       the cookie on exactly that hop and show the paywall a second
       time. */
    PAIVANA_COOKIE_NAME "=%llu-%s; %sPath=%s; Max-Age=%llu; HttpOnly;"
    " SameSite=Lax",
    (unsigned long long) cookie_expiration_s (expiration),
    cstr,
    use_https
    ? "Secure; "
    : "",
    url,
    max_age);
  GNUNET_free (epath);
  return res;
}


char *
PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp expiration,
                                  const char *website,
                                  const struct PAIVANA_Nonce *nonce)
{
  struct GNUNET_TIME_AbsoluteNBO e;
  char *res;
  gcry_md_hd_t hd;
  const void *sha256;
  char *cstr;
  size_t clen;

  e = GNUNET_TIME_absolute_hton (expiration.abs_time);
  GNUNET_assert (0 ==
                 gcry_md_open (&hd,
                               GCRY_MD_SHA256,
                               0));
  gcry_md_write (hd,
                 nonce,
                 sizeof (*nonce));
  gcry_md_write (hd,
                 website,
                 strlen (website) + 1);
  gcry_md_write (hd,
                 &e,
                 sizeof (e));
  sha256 = gcry_md_read (hd,
                         0);
  cstr = NULL;
  clen = GNUNET_STRINGS_base64url_encode (sha256,
                                          256 / 8,
                                          &cstr);
  GNUNET_asprintf (
    &res,
    "%llu-%.*s",
    (unsigned long long) (expiration.abs_time.abs_value_us / 1000LLU / 1000LLU),
    (int) clen,
    cstr);
  GNUNET_free (cstr);
  gcry_md_close (hd);
  return res;
}
