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

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

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

/**
 * @file test_cookie_access.c
 * @brief tests the access decision itself: which cookie values
 *        PAIVANA_HTTPD_check_cookie() grants access on, and the
 *        `paivana_id` the order is created under
 *
 * Its sibling test_cookie_header.c covers the `Set-Cookie` line paivana
 * emits -- whether the credential ever comes back.  This one covers
 * what happens when it does, which is the security boundary: the cookie
 * is a bearer token minted by us and handed to the party with the
 * strongest possible interest in widening it, so every one of
 *
 *   - the expiration it was minted for,
 *   - the website it was minted for,
 *   - the client address it was minted for
 *
 * has to be inside the MAC and has to be re-checked, and every way a
 * value can be malformed has to end in a rejection rather than in a
 * read of something that is not there.  None of this is
 * reachable from the integration suite: that runs paivana with -n,
 * where do_forward is set before the request is even looked at and the
 * cookie path is never entered.
 *
 * `expiration' is the subject of the second half.  It used to be called
 * `cur_time' and was read in two mutually exclusive ways at once -- as
 * the client's clock, bounded to 90 seconds ahead, and as the end of
 * the access being sold, which is what the cookie's Max-Age, this
 * file's time bound and the contract's max_pickup_time all treat it as.
 * The cases below pin the surviving reading: a cookie minted for an
 * hour out is good for an hour (under the other reading it could not be
 * minted at all), one minted for a moment already past is good for
 * nothing, and moving the expiration in the value breaks the MAC.
 */
#include "platform.h"
#include <gnunet/gnunet_util_lib.h>
#include "paivana-httpd_cookie.h"

/**
 * Globals that paivana-httpd.c normally defines; the cookie
 * compilation unit references them.
 */
int PH_global_cookie;
char *PH_base_url;

/**
 * Number of checks that did not hold.
 */
static unsigned int failures;

/**
 * The website most cases are about.
 */
#define WEBSITE "https://example.com/premium/article"

/**
 * A client address, in the shape get_client_address() hands over for
 * an IPv4 peer: the bare address bytes.
 */
static const uint8_t ca4[4] = { 203, 0, 113, 7 };

/**
 * A different client address of the same length.
 */
static const uint8_t ca4b[4] = { 203, 0, 113, 8 };

/**
 * An IPv6 client address, i.e. one of a different length.
 */
static const uint8_t ca6[16] = {
  0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x07
};


/**
 * Mint an access cookie and return its value, i.e. the `Set-Cookie`
 * line with the name and the attributes cut away -- what the browser
 * will send back in a `Cookie` header and what check_cookie() is
 * handed.
 *
 * @param website URL the cookie is minted for
 * @param expiration end of the access being granted
 * @param ca_len number of bytes in @a ca
 * @param ca client address the cookie is minted for
 * @return the cookie value, to be freed by the caller
 */
static char *
mint (const char *website,
      struct GNUNET_TIME_Timestamp expiration,
      size_t ca_len,
      const void *ca)
{
  char *sc;
  char *eq;
  char *semi;
  char *val;

  sc = PAIVANA_HTTPD_compute_cookie (expiration,
                                     website,
                                     ca_len,
                                     ca);
  eq = strchr (sc,
               '=');
  GNUNET_assert (NULL != eq);
  semi = strchr (eq,
                 ';');
  if (NULL != semi)
    *semi = '\0';
  val = GNUNET_strdup (eq + 1);
  GNUNET_free (sc);
  return val;
}


/**
 * Mint an access cookie for #WEBSITE, an hour out, for #ca4.
 *
 * @return the cookie value, to be freed by the caller
 */
static char *
mint_default (void)
{
  return mint (WEBSITE,
               GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
               sizeof (ca4),
               ca4);
}


/**
 * Check that @a value is (or is not) accepted for @a website and @a ca.
 *
 * @param label what the case is called in the log
 * @param value cookie value to present
 * @param website URL the access is claimed for
 * @param ca_len number of bytes in @a ca
 * @param ca client address the access is claimed from
 * @param want true if the cookie is expected to grant access
 */
static void
grants (const char *label,
        const char *value,
        const char *website,
        size_t ca_len,
        const void *ca,
        bool want)
{
  bool got;

  got = PAIVANA_HTTPD_check_cookie (value,
                                    website,
                                    ca_len,
                                    ca);
  if (got != want)
  {
    fprintf (stderr,
             "FAIL: %s -> %s, want %s\n",
             label,
             got ? "granted" : "refused",
             want ? "granted" : "refused");
    failures++;
  }
  else
  {
    fprintf (stderr,
             "  ok: %s -> %s\n",
             label,
             got ? "granted" : "refused");
  }
}


/**
 * Check that @a value is refused, whatever it is.  Used for the
 * malformed cases, where the point is that nothing is read past the
 * end of a value the client chose.
 *
 * @param label what the case is called in the log
 * @param value cookie value to present
 */
static void
refuses (const char *label,
         const char *value)
{
  grants (label,
          value,
          WEBSITE,
          sizeof (ca4),
          ca4,
          false);
}


/**
 * Check that @a id has the shape the paywall page and the merchant
 * both expect: decimal seconds, '-', then unpadded RFC 4648 section 5
 * base64url.  It travels as a merchant `session_id` and, before that,
 * through a URL.
 *
 * @param label what the case is called in the log
 * @param id the identifier to inspect
 */
static void
id_is_wellformed (const char *label,
                  const char *id)
{
  const char *dash;

  dash = strchr (id,
                 '-');
  if ( (NULL == dash) ||
       (dash == id) )
  {
    fprintf (stderr,
             "FAIL: %s: `%s' has no seconds prefix\n",
             label,
             id);
    failures++;
    return;
  }
  for (const char *p = id; p < dash; p++)
  {
    if (! isdigit ((unsigned char) *p))
    {
      fprintf (stderr,
               "FAIL: %s: `%s' has a non-digit in its seconds prefix\n",
               label,
               id);
      failures++;
      return;
    }
  }
  for (const char *p = dash + 1; '\0' != *p; p++)
  {
    if ( (isalnum ((unsigned char) *p)) ||
         ('-' == *p) ||
         ('_' == *p) )
      continue;
    fprintf (stderr,
             "FAIL: %s: `%s' carries `%c', which is not in the base64url"
             " alphabet\n",
             label,
             id,
             *p);
    failures++;
    return;
  }
  fprintf (stderr,
           "  ok: %s -> %s\n",
           label,
           id);
}


/**
 * Check that @a a and @a b differ.
 *
 * @param label what the case is called in the log
 * @param a first identifier
 * @param b second identifier
 */
static void
differ (const char *label,
        const char *a,
        const char *b)
{
  if (0 == strcmp (a,
                   b))
  {
    fprintf (stderr,
             "FAIL: %s: both inputs give `%s'\n",
             label,
             a);
    failures++;
  }
  else
  {
    fprintf (stderr,
             "  ok: %s\n",
             label);
  }
}


int
main (int argc,
      char *const *argv)
{
  struct GNUNET_TIME_Timestamp hour;
  struct GNUNET_TIME_Timestamp past;
  char *val;

  (void) argc;
  (void) argv;
  /* Quiet: every refusal below logs by design. */
  GNUNET_assert (GNUNET_OK ==
                 GNUNET_log_setup ("test-cookie-access",
                                   "ERROR",
                                   NULL));
  GNUNET_CRYPTO_hash ("test-cookie-access",
                      strlen ("test-cookie-access"),
                      &paivana_secret);
  hour = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS);
  /* A fixed instant in the past (2023-11-14), so that the expiry cases
     do not depend on how long the test takes to run. */
  past.abs_time = GNUNET_TIME_absolute_from_s (1700000000);

  fprintf (stderr,
           "-- a cookie grants what it was minted for --\n");
  val = mint_default ();
  grants ("the cookie just minted",
          val,
          WEBSITE,
          sizeof (ca4),
          ca4,
          true);
  GNUNET_free (val);

  fprintf (stderr,
           "-- ...and expires when it says it does --\n");
  /* An hour is an ordinary access duration and, before `expiration'
     was named that, an unreachable one: the pay endpoint refused any
     value more than 90 seconds ahead, so this cookie could not be
     obtained at all. */
  val = mint (WEBSITE,
              hour,
              sizeof (ca4),
              ca4);
  grants ("an expiration an hour out",
          val,
          WEBSITE,
          sizeof (ca4),
          ca4,
          true);
  GNUNET_free (val);
  /* compute_cookie() raises a sub-second Max-Age to 1, since a Max-Age
     of 0 would delete the cookie on arrival (RFC 6265 section 5.2.2).
     That is a floor on the attribute, not on the access: the value
     itself still carries the expiration, and this side must not honour
     a second the other side rounded up to. */
  val = mint (WEBSITE,
              past,
              sizeof (ca4),
              ca4);
  grants ("an expiration in the past",
          val,
          WEBSITE,
          sizeof (ca4),
          ca4,
          false);
  GNUNET_free (val);

  fprintf (stderr,
           "-- the expiration is inside the MAC --\n");
  {
    unsigned long long secs;
    const char *dash;
    char *forged;

    /* The holder of a cookie can read its expiration off the value and
       would like a later one.  Rewriting it has to fail: the timestamp
       is the KDF salt, so a value whose prefix no longer matches what
       was signed cannot verify. */
    val = mint_default ();
    GNUNET_assert (1 ==
                   sscanf (val,
                           "%llu-",
                           &secs));
    dash = strchr (val,
                   '-');
    GNUNET_assert (NULL != dash);
    GNUNET_asprintf (&forged,
                     "%llu%s",
                     secs + 365ULL * 24 * 60 * 60,
                     dash);
    grants ("an expiration moved a year forward",
            forged,
            WEBSITE,
            sizeof (ca4),
            ca4,
            false);
    GNUNET_free (forged);
    GNUNET_free (val);
  }

  fprintf (stderr,
           "-- the website is inside the MAC --\n");
  val = mint_default ();
  grants ("presented for a different site",
          val,
          "https://example.com/premium/other",
          sizeof (ca4),
          ca4,
          false);
  grants ("presented for a different host",
          val,
          "https://evil.example.net/premium/article",
          sizeof (ca4),
          ca4,
          false);
  grants ("presented for a different scheme",
          val,
          "http://example.com/premium/article",
          sizeof (ca4),
          ca4,
          false);
  GNUNET_free (val);

  fprintf (stderr,
           "-- the client address is inside the MAC --\n");
  /* This is what stops a paid cookie from being passed around, and it
     is why get_client_address() has to be right about who the client
     is: whoever the address says paid, paid for everyone at it. */
  val = mint_default ();
  grants ("presented from a neighbouring address",
          val,
          WEBSITE,
          sizeof (ca4b),
          ca4b,
          false);
  grants ("presented from an address of a different family",
          val,
          WEBSITE,
          sizeof (ca6),
          ca6,
          false);
  grants ("presented from no address at all",
          val,
          WEBSITE,
          0,
          "",
          false);
  GNUNET_free (val);
  /* An IPv6 client is not a special case, just a longer one. */
  val = mint (WEBSITE,
              hour,
              sizeof (ca6),
              ca6);
  grants ("an IPv6 cookie for its own address",
          val,
          WEBSITE,
          sizeof (ca6),
          ca6,
          true);
  grants ("an IPv6 cookie for a truncation of it",
          val,
          WEBSITE,
          sizeof (ca4),
          ca6,
          false);
  GNUNET_free (val);

  fprintf (stderr,
           "-- malformed values are refused, not parsed --\n");
  /* Every one of these is a string an attacker can put in a Cookie
     header, and between them they take every exit check_cookie() has
     before it gets as far as comparing MACs: no separator at all, a
     seconds field that is not a bare decimal count ending at the
     separator, and a hash that does not decode. */
  refuses ("the empty value",
           "");
  refuses ("a value with no '-' at all",
           "deadbeef");
  refuses ("a '-' with no seconds before it",
           "-ABCDEF");
  refuses ("a non-numeric prefix",
           "soon-ABCDEF");
  {
    unsigned long long secs;
    char *bad;

    /* A well-formed, unexpired prefix followed by something that is
       not the base32 encoding of a hash: the length check and the
       alphabet check both live in string_to_data(). */
    secs = (unsigned long long)
           (GNUNET_TIME_timestamp_to_s (hour));
    GNUNET_asprintf (&bad,
                     "%llu-",
                     secs);
    refuses ("a prefix with an empty hash",
             bad);
    GNUNET_free (bad);
    GNUNET_asprintf (&bad,
                     "%llu-!!!!!!!!",
                     secs);
    refuses ("a hash outside the base32 alphabet",
             bad);
    GNUNET_free (bad);
    GNUNET_asprintf (&bad,
                     "%llu-ABCDEFGH",
                     secs);
    refuses ("a hash of the wrong length",
             bad);
    GNUNET_free (bad);
  }
  {
    /* A cookie of ours, respelled.  These are not malformed values in
       the sense above -- every one of them decodes to the same seconds
       and the same hash as a value we really issued, so a parser that
       accepts them accepts a second, third and fourth spelling of one
       live credential.  That is what a bare `sscanf ("%llu-")' did: it
       took a leading '+', leading whitespace and leading zeros, and its
       trailing '-' verified nothing, so anything at all could sit
       between the seconds and the separator.  A cookie has exactly one
       spelling or the set of strings that grant access is not the set
       we minted. */
    char *good = mint_default ();
    const char *gdash = strchr (good,
                                '-');
    char *bad;

    GNUNET_assert (NULL != gdash);
    GNUNET_asprintf (&bad,
                     "+%s",
                     good);
    refuses ("a live cookie with a '+' before the seconds",
             bad);
    GNUNET_free (bad);
    GNUNET_asprintf (&bad,
                     " %s",
                     good);
    refuses ("a live cookie with a space before the seconds",
             bad);
    GNUNET_free (bad);
    GNUNET_asprintf (&bad,
                     "0%s",
                     good);
    refuses ("a live cookie with a leading zero on the seconds",
             bad);
    GNUNET_free (bad);
    GNUNET_asprintf (&bad,
                     "%.*sx%s",
                     (int) (gdash - good),
                     good,
                     gdash);
    refuses ("a live cookie with junk between seconds and '-'",
             bad);
    GNUNET_free (bad);
    GNUNET_asprintf (&bad,
                     "99999999999999999999999%s",
                     gdash);
    refuses ("a seconds count that does not fit",
             bad);
    GNUNET_free (bad);
    GNUNET_free (good);
  }
  {
    char *val2;
    char *swapped;
    const char *dash;
    unsigned long long secs;

    /* A syntactically perfect value whose hash simply is not ours:
       the last exit, the one the MAC comparison itself takes. */
    val = mint_default ();
    val2 = mint (WEBSITE,
                 hour,
                 sizeof (ca4b),
                 ca4b);
    dash = strchr (val2,
                   '-');
    GNUNET_assert (NULL != dash);
    GNUNET_assert (1 ==
                   sscanf (val,
                           "%llu-",
                           &secs));
    GNUNET_asprintf (&swapped,
                     "%llu%s",
                     secs,
                     dash);
    refuses ("another client's hash under our own timestamp",
             swapped);
    GNUNET_free (swapped);
    GNUNET_free (val2);
    GNUNET_free (val);
  }

  fprintf (stderr,
           "-- -g scopes the cookie to the whole deployment --\n");
  /* With PH_global_cookie the website drops out of the KDF, so one
     cookie is good everywhere paivana serves.  That is the point of
     the switch; what matters is that it is symmetric, i.e. that
     flipping it invalidates the cookies minted under the other
     setting rather than silently widening or narrowing access. */
  PH_global_cookie = 1;
  val = mint (WEBSITE,
              hour,
              sizeof (ca4),
              ca4);
  grants ("-g: a cookie minted elsewhere on the site",
          val,
          "https://example.com/other/article",
          sizeof (ca4),
          ca4,
          true);
  grants ("-g: still bound to the client address",
          val,
          "https://example.com/other/article",
          sizeof (ca4b),
          ca4b,
          false);
  PH_global_cookie = 0;
  grants ("-g cookie presented after -g was turned off",
          val,
          WEBSITE,
          sizeof (ca4),
          ca4,
          false);
  GNUNET_free (val);
  val = mint (WEBSITE,
              hour,
              sizeof (ca4),
              ca4);
  PH_global_cookie = 1;
  grants ("non-global cookie presented after -g was turned on",
          val,
          WEBSITE,
          sizeof (ca4),
          ca4,
          false);
  PH_global_cookie = 0;
  GNUNET_free (val);

  fprintf (stderr,
           "-- the paivana_id --\n");
  {
    struct PAIVANA_Nonce nonce;
    struct PAIVANA_Nonce nonce2;
    static const uint8_t nonce_bytes[16] = {
      0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
    };
    /* Computed independently from the same definition the paywall page
       implements (src/frontend/paywall.js makePaivanaId()):
         base64url (SHA-256 (nonce || website || '\0' || be64 (usec)))
       with the padding stripped, prefixed by the expiration in seconds.
       Both ends derive this from their own copy of the inputs and never
       exchange it, so the two implementations agreeing is the whole
       protocol: a change on either side that this vector does not
       survive means every order is created under an id the other side
       will not look for.  Note that the client hashes the expiration,
       which is why the pay endpoint cannot re-derive it and has to take
       the client's word for it (bounded by the contract). */
    static const char *want =
      "1700000000-gAGOp3pVpQq9VTA7P0cYqqKTiFGLxC_9FLZZXkmZwGc";
    char *id;
    char *id2;

    /* memcpy rather than an initialiser list: the nonce is hashed as
       the bytes it is in memory, and a `uint32_t val[4]' written as
       integers would give a different digest on a big-endian host. */
    memcpy (&nonce,
            nonce_bytes,
            sizeof (nonce));
    id = PAIVANA_HTTPD_compute_paivana_id (past,
                                           WEBSITE,
                                           &nonce);
    if (0 != strcmp (id,
                     want))
    {
      fprintf (stderr,
               "FAIL: paivana_id is `%s', want `%s'\n",
               id,
               want);
      failures++;
    }
    else
    {
      fprintf (stderr,
               "  ok: paivana_id matches the paywall page's derivation\n");
    }
    id_is_wellformed ("the shape of a paivana_id",
                      id);

    /* All three inputs have to reach the digest. */
    id2 = PAIVANA_HTTPD_compute_paivana_id (hour,
                                            WEBSITE,
                                            &nonce);
    differ ("a different expiration gives a different id",
            id,
            id2);
    GNUNET_free (id2);
    id2 = PAIVANA_HTTPD_compute_paivana_id (past,
                                            "https://example.com/premium/other",
                                            &nonce);
    differ ("a different website gives a different id",
            id,
            id2);
    GNUNET_free (id2);
    memcpy (&nonce2,
            nonce_bytes,
            sizeof (nonce2));
    nonce2.val[3] ^= 1;
    id2 = PAIVANA_HTTPD_compute_paivana_id (past,
                                            WEBSITE,
                                            &nonce2);
    differ ("a different nonce gives a different id",
            id,
            id2);
    GNUNET_free (id2);
    /* The whole website string reaches the digest, terminating NUL
       included -- not some prefix of it. */
    id2 = PAIVANA_HTTPD_compute_paivana_id (past,
                                            WEBSITE "x",
                                            &nonce);
    differ ("a website that extends another gives a different id",
            id,
            id2);
    GNUNET_free (id2);
    /* ...and it is a pure function, or the order and the redemption
       would never line up. */
    id2 = PAIVANA_HTTPD_compute_paivana_id (past,
                                            WEBSITE,
                                            &nonce);
    if (0 != strcmp (id,
                     id2))
    {
      fprintf (stderr,
               "FAIL: paivana_id is not deterministic: `%s' then `%s'\n",
               id,
               id2);
      failures++;
    }
    else
    {
      fprintf (stderr,
               "  ok: paivana_id is deterministic\n");
    }
    GNUNET_free (id2);
    GNUNET_free (id);
  }

  if (0 != failures)
  {
    fprintf (stderr,
             "%u check(s) failed\n",
             failures);
    return 1;
  }
  fprintf (stderr,
           "all checks passed\n");
  return 0;
}
