paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

paivana-httpd_cookie.c (16899B)


      1 /*
      2   This file is part of GNU Taler
      3   Copyright (C) 2026 Taler Systems SA
      4 
      5   GNU Taler is free software; you can redistribute it and/or
      6   modify it under the terms of the GNU Affero General Public License
      7   as published by the Free Software Foundation; either version
      8   3, or (at your option) any later version.
      9 
     10   GNU Taler is distributed in the hope that it will be useful, but
     11   WITHOUT ANY WARRANTY; without even the implied warranty of
     12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13   GNU Affero General Public License for more details.
     14 
     15   You should have received a copy of the GNU Affero General Public
     16   License along with GNU Taler; see the file COPYING.  If not,
     17   write to the Free Software Foundation, Inc., 51 Franklin
     18   Street, Fifth Floor, Boston, MA 02110-1301, USA.
     19 */
     20 
     21 /**
     22  * @author Christian Grothoff
     23  * @file src/backend/paivana-httpd_cookie.c
     24  * @brief Cookie computation logic for paivana
     25  */
     26 #include "platform.h"
     27 #include <curl/curl.h>
     28 #include <gcrypt.h>
     29 #include <gnunet/gnunet_util_lib.h>
     30 #include <taler/taler_mhd_lib.h>
     31 #include "paivana-httpd_cookie.h"
     32 #include "paivana-httpd.h"
     33 
     34 
     35 /**
     36  * Secret for the cookie generation.
     37  */
     38 struct GNUNET_HashCode paivana_secret;
     39 
     40 
     41 /**
     42  * Reduce @a expiration to the granularity the cookie is able to
     43  * carry.
     44  *
     45  * The `Set-Cookie` value transmits the expiration in seconds while
     46  * the MAC covers it in microseconds, so the two agree only if the MAC
     47  * is taken over the microsecond value those transmitted seconds decode
     48  * back to.  For a `struct GNUNET_TIME_Timestamp` proper -- "a round
     49  * number of seconds in microseconds", gnunet_time_lib.h -- that is the
     50  * value itself, but nothing enforces the contract at this boundary:
     51  * #GNUNET_TIME_UNIT_FOREVER_TS is UINT64_MAX microseconds, which is
     52  * not a round number of seconds, and reaches us straight from the
     53  * client as `{"expiration":{"t_s":"never"}}'.  Deriving both the wire
     54  * value and the MAC input from this one function makes the round trip
     55  * an identity instead of an invariant every caller has to know about;
     56  * a cookie minted for such an expiration used to verify against a
     57  * different digest than the one it was signed with, so the client paid
     58  * and received a cookie that could never grant it anything.
     59  *
     60  * @param expiration end of the access being granted
     61  * @return @a expiration in seconds since the epoch
     62  */
     63 static uint64_t
     64 cookie_expiration_s (struct GNUNET_TIME_Timestamp expiration)
     65 {
     66   return expiration.abs_time.abs_value_us
     67          / GNUNET_TIME_UNIT_SECONDS.rel_value_us;
     68 }
     69 
     70 
     71 /**
     72  * Compute access cookie hash for the given @a expiration, the
     73  * @a website and @a ca.
     74  *
     75  * @param expiration time at which the access being granted ends
     76  * @param website URL the cookie is valid for
     77  * @param ca_len number of bytes in @a ca
     78  * @param ca client (IP) address
     79  * @param[out] c set to the cookie hash
     80  */
     81 static void
     82 compute_cookie_hash (struct GNUNET_TIME_Timestamp expiration,
     83                      const char *website,
     84                      size_t ca_len,
     85                      const void *ca,
     86                      struct GNUNET_HashCode *c)
     87 {
     88   struct GNUNET_TIME_AbsoluteNBO e;
     89 
     90   if (NULL == ca)
     91   {
     92     /* The client address is optional at the #PAIVANA_HTTPD_check_cookie()
     93        call site, which falls back to a zero-length address when MHD
     94        cannot tell it one.  Both memcpy() and gcry_md_write() are
     95        undefined on a NULL pointer even for a zero length, so hand them
     96        something to point at; the digest sees the same nothing. */
     97     GNUNET_assert (0 == ca_len);
     98     ca = "";
     99   }
    100   GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
    101               "Computing cookie for %s expiring at %llu and client %s\n",
    102               website,
    103               (unsigned long long) cookie_expiration_s (expiration),
    104               TALER_b2s (ca,
    105                          ca_len));
    106   e = GNUNET_TIME_absolute_hton (
    107     GNUNET_TIME_absolute_from_s (cookie_expiration_s (expiration)));
    108   if (PH_global_cookie)
    109     website = "";
    110   GNUNET_assert (GNUNET_YES ==
    111                  GNUNET_CRYPTO_hkdf_gnunet (
    112                    c,            /* result */
    113                    sizeof (*c),
    114                    &e,          /* salt */
    115                    sizeof (e),
    116                    &paivana_secret, /* source key material */
    117                    sizeof (paivana_secret),
    118                    GNUNET_CRYPTO_kdf_arg (website,
    119                                           strlen (website) + 1),
    120                    GNUNET_CRYPTO_kdf_arg (ca,
    121                                           ca_len)));
    122 }
    123 
    124 
    125 bool
    126 PAIVANA_HTTPD_check_cookie (const char *cookie,
    127                             const char *website,
    128                             size_t ca_len,
    129                             const void *ca)
    130 {
    131   const char *dash;
    132   char *endptr;
    133   unsigned long long u;
    134   struct GNUNET_HashCode h;
    135   struct GNUNET_HashCode c;
    136   struct GNUNET_TIME_Timestamp a;
    137 
    138   dash = strchr (cookie,
    139                  '-');
    140   if (NULL == dash)
    141   {
    142     GNUNET_break_op (0);
    143     return false;
    144   }
    145   /* The seconds are a bare decimal count.  strtoull() on its own would
    146      also take leading whitespace, a '+' or '-' sign and leading zeros
    147      -- none of which we ever emit, and each of which is a second
    148      spelling of a cookie we already handed out -- so the first
    149      character is checked here rather than left to it.  What verifies
    150      the literal '-' is the comparison against @a dash: it is the one
    151      thing a "%llu-" format string looks like it establishes and does
    152      not. */
    153   if ( (! isdigit ((unsigned char) cookie[0])) ||
    154        ( ('0' == cookie[0]) &&
    155          (cookie + 1 != dash) ) )
    156   {
    157     GNUNET_break_op (0);
    158     return false;
    159   }
    160   errno = 0;
    161   u = strtoull (cookie,
    162                 &endptr,
    163                 10);
    164   if ( (0 != errno) ||
    165        (endptr != dash) )
    166   {
    167     GNUNET_break_op (0);
    168     return false;
    169   }
    170   dash++;
    171   if (GNUNET_OK !=
    172       GNUNET_STRINGS_string_to_data (dash,
    173                                      strlen (dash),
    174                                      &c,
    175                                      sizeof (c)))
    176   {
    177     GNUNET_break_op (0);
    178     return false;
    179   }
    180   a.abs_time = GNUNET_TIME_absolute_from_s (u);
    181   compute_cookie_hash (a,
    182                        website,
    183                        ca_len,
    184                        ca,
    185                        &h);
    186   if (0 !=
    187       GNUNET_memcmp_priv (&c,
    188                           &h))
    189   {
    190     GNUNET_log (GNUNET_ERROR_TYPE_INFO,
    191                 "Cookie hash does not match!\n");
    192     return false;
    193   }
    194   /* The expiration is examined only now that the value is known to be
    195      one of ours.  Testing it first was not a weakness -- it is the HKDF
    196      salt, so a tampered expiration fails the MAC rather than being
    197      believed -- but it acted on, and logged, a field no one had
    198      authenticated yet, which let any client write a timestamp of its
    199      choosing into the log by presenting a made-up cookie.  The price is
    200      one HKDF on a value that was going to be rejected anyway. */
    201   if (GNUNET_TIME_absolute_is_past (a.abs_time))
    202   {
    203     GNUNET_log (GNUNET_ERROR_TYPE_INFO,
    204                 "Cookie expired %s ago\n",
    205                 GNUNET_TIME_relative2s (
    206                   GNUNET_TIME_absolute_get_duration (a.abs_time),
    207                   true));
    208     return false;
    209   }
    210   return true;
    211 }
    212 
    213 
    214 /**
    215  * Is @a c a character a cookie `Path` attribute may carry literally?
    216  *
    217  * The value has to satisfy two grammars at once.  RFC 3986 section 3.3
    218  * gives what a URI path may spell out:
    219  *   pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
    220  * and RFC 6265 section 4.1.1 gives what the attribute may contain:
    221  *   path-value = <any CHAR except CTLs or ";">
    222  * so the literal set is pchar without ';', plus the '/' that separates
    223  * segments.  Everything else is what a browser percent-encodes in the
    224  * request-URI, and hence what RFC 6265 section 5.1.4 path-match will
    225  * compare against.
    226  *
    227  * @param c character to classify
    228  * @return true if @a c may be emitted as-is
    229  */
    230 static bool
    231 path_char_literal_ok (unsigned char c)
    232 {
    233   if ( ( ('a' <= c) && ('z' >= c) ) ||
    234        ( ('A' <= c) && ('Z' >= c) ) ||
    235        ( ('0' <= c) && ('9' >= c) ) )
    236     return true;                        /* unreserved, RFC 3986 sec 2.3 */
    237   if ('\0' == c)
    238     return false;
    239   return NULL != strchr ("-._~"         /* unreserved, RFC 3986 sec 2.3 */
    240                          "!$&'()*+,="   /* sub-delims minus ';', sec 2.2 */
    241                          ":@"           /* the rest of pchar, sec 3.3 */
    242                          "/",           /* segment separator, sec 3.3 */
    243                          c);
    244 }
    245 
    246 
    247 /**
    248  * Render @a path as an RFC 6265 section 4.1.1 `path-value` that
    249  * path-matches the requests a browser will actually make for it.
    250  *
    251  * The path we are handed has been through MHD, which percent-decodes
    252  * the request URI before the handler sees it, and then through the
    253  * client, which echoes it back to the payment endpoint.  The browser,
    254  * however, matches the stored cookie-path against the *request* path
    255  * (RFC 6265 sections 5.1.4 and 5.4), which is the encoded one -- so a
    256  * decoded path is emitted only to never match again.  Re-encode
    257  * everything outside the literal set, and pass an existing
    258  * percent-triplet through unchanged so that an already-encoded path
    259  * does not get encoded twice.  A literal '%' that happens to be
    260  * followed by two hex digits is indistinguishable from a triplet here;
    261  * that ambiguity is inherent to having been decoded once already.
    262  *
    263  * @param path path component of the website URL, starting at its '/'
    264  * @return the attribute value, or NULL if @a path cannot be expressed
    265  *         as one; the caller then has to fall back to "/"
    266  */
    267 static char *
    268 encode_path (const char *path)
    269 {
    270   static const char hex[] = "0123456789ABCDEF";
    271   size_t len;
    272   size_t off = 0;
    273   char *res;
    274 
    275   if ('/' != path[0])
    276   {
    277     /* RFC 6265 section 5.2.4: a Path that does not begin with '/' is
    278        discarded by the user agent in favour of the default-path. */
    279     GNUNET_break (0);
    280     return NULL;
    281   }
    282   /* RFC 3986 section 3.3: the path ends at the first '?' or '#'; a
    283      query or a fragment is not part of it.  The request-path of
    284      RFC 6265 section 5.1.4 is likewise taken "without the %x3F ('?')
    285      character or query string", so leaving one in yields a cookie-path
    286      nothing can ever path-match. */
    287   len = strcspn (path,
    288                  "?#");
    289   res = GNUNET_malloc (3 * len + 1);
    290   for (size_t i = 0; i<len; i++)
    291   {
    292     unsigned char c = (unsigned char) path[i];
    293 
    294     if (';' == c)
    295     {
    296       /* RFC 6265 section 4.1.1 excludes ';' from path-value outright
    297          (it would start the next cookie-av), while RFC 3986 section 3.3
    298          allows it in a path as a sub-delim.  Percent-encoding it would
    299          satisfy the grammar but no longer path-match the request, so
    300          such a path simply cannot be expressed. */
    301       GNUNET_free (res);
    302       return NULL;
    303     }
    304     if ( ('%' == c) &&
    305          (i + 2 < len) &&
    306          (isxdigit ((unsigned char) path[i + 1])) &&
    307          (isxdigit ((unsigned char) path[i + 2])) )
    308     {
    309       res[off++] = path[i];
    310       res[off++] = path[i + 1];
    311       res[off++] = path[i + 2];
    312       i += 2;
    313       continue;
    314     }
    315     if (path_char_literal_ok (c))
    316     {
    317       res[off++] = (char) c;
    318       continue;
    319     }
    320     res[off++] = '%';
    321     res[off++] = hex[c >> 4];
    322     res[off++] = hex[c & 15];
    323   }
    324   res[off] = '\0';
    325   return res;
    326 }
    327 
    328 
    329 char *
    330 PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp expiration,
    331                               const char *website,
    332                               size_t ca_len,
    333                               const void *ca)
    334 {
    335   struct GNUNET_HashCode h;
    336   char *end;
    337   char cstr[128];
    338   char *res;
    339   char *epath = NULL;
    340   const char *url = "/";
    341   /* RFC 6265 section 4.1.2.5: `Secure` says the credential must never
    342      leave the user agent over an unsecured channel.  Deciding that from
    343      the website URL alone would let whoever picked that URL decide it:
    344      for an order carrying a fulfillment_url the string comes from the
    345      order, and the pay endpoint only checks it against the contract.
    346      BASE_URL is the operator's own statement about the scheme clients
    347      reach us with -- including the usual deployment where a reverse
    348      proxy terminates the TLS and we ourselves only ever see plaintext
    349      -- so it wins wherever it is configured, and the website URL is
    350      consulted only when it is not.  Note that the transport itself
    351      answers a different question: paivana never passes MHD_USE_TLS
    352      (see PAIVANA_HTTPD_serve_requests()), so
    353      #MHD_CONNECTION_INFO_PROTOCOL says "plaintext" in every deployment
    354      that exists today, and taking it as the sole source would drop
    355      `Secure` from every cookie we hand out. */
    356   bool use_https = (0 ==
    357                     strncasecmp ((NULL != PH_base_url)
    358                                  ? PH_base_url
    359                                  : website,
    360                                  "https://",
    361                                  strlen ("https://")));
    362   struct GNUNET_TIME_Relative duration
    363     = GNUNET_TIME_absolute_get_remaining (expiration.abs_time);
    364   /* RFC 6265 section 5.2.2: a non-positive Max-Age tells the user agent
    365      to expire the cookie immediately.  Truncating to seconds turns any
    366      lifetime below one second into exactly that, so the client would
    367      pay and have the cookie deleted on arrival; give it the one second
    368      the wire format is able to express instead. */
    369   unsigned long long max_age
    370     = GNUNET_MAX (1LLU,
    371                   (unsigned long long) (duration.rel_value_us
    372                                         / GNUNET_TIME_UNIT_SECONDS.
    373                                         rel_value_us));
    374 
    375   if (! PH_global_cookie)
    376   {
    377     const char *dslash = strstr (website,
    378                                  "//");
    379     const char *path = NULL;
    380 
    381     if (NULL != dslash)
    382       path = strchr (dslash + 2,
    383                      '/');
    384     if (NULL != path)
    385     {
    386       epath = encode_path (path);
    387       if (NULL != epath)
    388         url = epath;
    389       else
    390         GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
    391                     "Path of `%s' is not expressible as a cookie Path"
    392                     " attribute; scoping the access cookie to `/'\n",
    393                     website);
    394     }
    395   }
    396   compute_cookie_hash (expiration,
    397                        website,
    398                        ca_len,
    399                        ca,
    400                        &h);
    401   end = GNUNET_STRINGS_data_to_string (&h,
    402                                        sizeof (h),
    403                                        cstr,
    404                                        sizeof (cstr));
    405   GNUNET_assert (NULL != end);
    406   *end = '\0';
    407   GNUNET_asprintf (
    408     &res,
    409     /* RFC 6265 section 4.1.1:
    410          set-cookie-string = cookie-pair *( ";" SP cookie-av )
    411        -- every ';' is followed by SP and an attribute, so the value
    412        must not end on one.
    413 
    414        `SameSite=Lax' is what browsers already default to (RFC 6265bis
    415        section 5.4.7), stated rather than inherited: this cookie is a
    416        bearer credential for paid access, and a default is not the place
    417        to leave that.  Lax and not Strict, because the access it grants
    418        is reached by top-level navigation -- the wallet sends the client
    419        back to the article it just paid for -- and Strict would withhold
    420        the cookie on exactly that hop and show the paywall a second
    421        time. */
    422     PAIVANA_COOKIE_NAME "=%llu-%s; %sPath=%s; Max-Age=%llu; HttpOnly;"
    423     " SameSite=Lax",
    424     (unsigned long long) cookie_expiration_s (expiration),
    425     cstr,
    426     use_https
    427     ? "Secure; "
    428     : "",
    429     url,
    430     max_age);
    431   GNUNET_free (epath);
    432   return res;
    433 }
    434 
    435 
    436 char *
    437 PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp expiration,
    438                                   const char *website,
    439                                   const struct PAIVANA_Nonce *nonce)
    440 {
    441   struct GNUNET_TIME_AbsoluteNBO e;
    442   char *res;
    443   gcry_md_hd_t hd;
    444   const void *sha256;
    445   char *cstr;
    446   size_t clen;
    447 
    448   e = GNUNET_TIME_absolute_hton (expiration.abs_time);
    449   GNUNET_assert (0 ==
    450                  gcry_md_open (&hd,
    451                                GCRY_MD_SHA256,
    452                                0));
    453   gcry_md_write (hd,
    454                  nonce,
    455                  sizeof (*nonce));
    456   gcry_md_write (hd,
    457                  website,
    458                  strlen (website) + 1);
    459   gcry_md_write (hd,
    460                  &e,
    461                  sizeof (e));
    462   sha256 = gcry_md_read (hd,
    463                          0);
    464   cstr = NULL;
    465   clen = GNUNET_STRINGS_base64url_encode (sha256,
    466                                           256 / 8,
    467                                           &cstr);
    468   GNUNET_asprintf (
    469     &res,
    470     "%llu-%.*s",
    471     (unsigned long long) (expiration.abs_time.abs_value_us / 1000LLU / 1000LLU),
    472     (int) clen,
    473     cstr);
    474   GNUNET_free (cstr);
    475   gcry_md_close (hd);
    476   return res;
    477 }