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 }