test_cookie_access.c (23011B)
1 /* 2 This file is part of GNUnet. 3 Copyright (C) 2026 Taler Systems SA 4 5 Paivana 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 Paivana is distributed in the hope that it will be useful, 11 but WITHOUT ANY WARRANTY; without even the implied warranty 12 of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See 13 the 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 Paivana; 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 * @file test_cookie_access.c 23 * @brief tests the access decision itself: which cookie values 24 * PAIVANA_HTTPD_check_cookie() grants access on, and the 25 * `paivana_id` the order is created under 26 * 27 * Its sibling test_cookie_header.c covers the `Set-Cookie` line paivana 28 * emits -- whether the credential ever comes back. This one covers 29 * what happens when it does, which is the security boundary: the cookie 30 * is a bearer token minted by us and handed to the party with the 31 * strongest possible interest in widening it, so every one of 32 * 33 * - the expiration it was minted for, 34 * - the website it was minted for, 35 * - the client address it was minted for 36 * 37 * has to be inside the MAC and has to be re-checked, and every way a 38 * value can be malformed has to end in a rejection rather than in a 39 * read of something that is not there. None of this is 40 * reachable from the integration suite: that runs paivana with -n, 41 * where do_forward is set before the request is even looked at and the 42 * cookie path is never entered. 43 * 44 * `expiration' is the subject of the second half. It used to be called 45 * `cur_time' and was read in two mutually exclusive ways at once -- as 46 * the client's clock, bounded to 90 seconds ahead, and as the end of 47 * the access being sold, which is what the cookie's Max-Age, this 48 * file's time bound and the contract's max_pickup_time all treat it as. 49 * The cases below pin the surviving reading: a cookie minted for an 50 * hour out is good for an hour (under the other reading it could not be 51 * minted at all), one minted for a moment already past is good for 52 * nothing, and moving the expiration in the value breaks the MAC. 53 */ 54 #include "platform.h" 55 #include <gnunet/gnunet_util_lib.h> 56 #include "paivana-httpd_cookie.h" 57 58 /** 59 * Globals that paivana-httpd.c normally defines; the cookie 60 * compilation unit references them. 61 */ 62 int PH_global_cookie; 63 char *PH_base_url; 64 65 /** 66 * Number of checks that did not hold. 67 */ 68 static unsigned int failures; 69 70 /** 71 * The website most cases are about. 72 */ 73 #define WEBSITE "https://example.com/premium/article" 74 75 /** 76 * A client address, in the shape get_client_address() hands over for 77 * an IPv4 peer: the bare address bytes. 78 */ 79 static const uint8_t ca4[4] = { 203, 0, 113, 7 }; 80 81 /** 82 * A different client address of the same length. 83 */ 84 static const uint8_t ca4b[4] = { 203, 0, 113, 8 }; 85 86 /** 87 * An IPv6 client address, i.e. one of a different length. 88 */ 89 static const uint8_t ca6[16] = { 90 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x07 91 }; 92 93 94 /** 95 * Mint an access cookie and return its value, i.e. the `Set-Cookie` 96 * line with the name and the attributes cut away -- what the browser 97 * will send back in a `Cookie` header and what check_cookie() is 98 * handed. 99 * 100 * @param website URL the cookie is minted for 101 * @param expiration end of the access being granted 102 * @param ca_len number of bytes in @a ca 103 * @param ca client address the cookie is minted for 104 * @return the cookie value, to be freed by the caller 105 */ 106 static char * 107 mint (const char *website, 108 struct GNUNET_TIME_Timestamp expiration, 109 size_t ca_len, 110 const void *ca) 111 { 112 char *sc; 113 char *eq; 114 char *semi; 115 char *val; 116 117 sc = PAIVANA_HTTPD_compute_cookie (expiration, 118 website, 119 ca_len, 120 ca); 121 eq = strchr (sc, 122 '='); 123 GNUNET_assert (NULL != eq); 124 semi = strchr (eq, 125 ';'); 126 if (NULL != semi) 127 *semi = '\0'; 128 val = GNUNET_strdup (eq + 1); 129 GNUNET_free (sc); 130 return val; 131 } 132 133 134 /** 135 * Mint an access cookie for #WEBSITE, an hour out, for #ca4. 136 * 137 * @return the cookie value, to be freed by the caller 138 */ 139 static char * 140 mint_default (void) 141 { 142 return mint (WEBSITE, 143 GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), 144 sizeof (ca4), 145 ca4); 146 } 147 148 149 /** 150 * Check that @a value is (or is not) accepted for @a website and @a ca. 151 * 152 * @param label what the case is called in the log 153 * @param value cookie value to present 154 * @param website URL the access is claimed for 155 * @param ca_len number of bytes in @a ca 156 * @param ca client address the access is claimed from 157 * @param want true if the cookie is expected to grant access 158 */ 159 static void 160 grants (const char *label, 161 const char *value, 162 const char *website, 163 size_t ca_len, 164 const void *ca, 165 bool want) 166 { 167 bool got; 168 169 got = PAIVANA_HTTPD_check_cookie (value, 170 website, 171 ca_len, 172 ca); 173 if (got != want) 174 { 175 fprintf (stderr, 176 "FAIL: %s -> %s, want %s\n", 177 label, 178 got ? "granted" : "refused", 179 want ? "granted" : "refused"); 180 failures++; 181 } 182 else 183 { 184 fprintf (stderr, 185 " ok: %s -> %s\n", 186 label, 187 got ? "granted" : "refused"); 188 } 189 } 190 191 192 /** 193 * Check that @a value is refused, whatever it is. Used for the 194 * malformed cases, where the point is that nothing is read past the 195 * end of a value the client chose. 196 * 197 * @param label what the case is called in the log 198 * @param value cookie value to present 199 */ 200 static void 201 refuses (const char *label, 202 const char *value) 203 { 204 grants (label, 205 value, 206 WEBSITE, 207 sizeof (ca4), 208 ca4, 209 false); 210 } 211 212 213 /** 214 * Check that @a id has the shape the paywall page and the merchant 215 * both expect: decimal seconds, '-', then unpadded RFC 4648 section 5 216 * base64url. It travels as a merchant `session_id` and, before that, 217 * through a URL. 218 * 219 * @param label what the case is called in the log 220 * @param id the identifier to inspect 221 */ 222 static void 223 id_is_wellformed (const char *label, 224 const char *id) 225 { 226 const char *dash; 227 228 dash = strchr (id, 229 '-'); 230 if ( (NULL == dash) || 231 (dash == id) ) 232 { 233 fprintf (stderr, 234 "FAIL: %s: `%s' has no seconds prefix\n", 235 label, 236 id); 237 failures++; 238 return; 239 } 240 for (const char *p = id; p < dash; p++) 241 { 242 if (! isdigit ((unsigned char) *p)) 243 { 244 fprintf (stderr, 245 "FAIL: %s: `%s' has a non-digit in its seconds prefix\n", 246 label, 247 id); 248 failures++; 249 return; 250 } 251 } 252 for (const char *p = dash + 1; '\0' != *p; p++) 253 { 254 if ( (isalnum ((unsigned char) *p)) || 255 ('-' == *p) || 256 ('_' == *p) ) 257 continue; 258 fprintf (stderr, 259 "FAIL: %s: `%s' carries `%c', which is not in the base64url" 260 " alphabet\n", 261 label, 262 id, 263 *p); 264 failures++; 265 return; 266 } 267 fprintf (stderr, 268 " ok: %s -> %s\n", 269 label, 270 id); 271 } 272 273 274 /** 275 * Check that @a a and @a b differ. 276 * 277 * @param label what the case is called in the log 278 * @param a first identifier 279 * @param b second identifier 280 */ 281 static void 282 differ (const char *label, 283 const char *a, 284 const char *b) 285 { 286 if (0 == strcmp (a, 287 b)) 288 { 289 fprintf (stderr, 290 "FAIL: %s: both inputs give `%s'\n", 291 label, 292 a); 293 failures++; 294 } 295 else 296 { 297 fprintf (stderr, 298 " ok: %s\n", 299 label); 300 } 301 } 302 303 304 int 305 main (int argc, 306 char *const *argv) 307 { 308 struct GNUNET_TIME_Timestamp hour; 309 struct GNUNET_TIME_Timestamp past; 310 char *val; 311 312 (void) argc; 313 (void) argv; 314 /* Quiet: every refusal below logs by design. */ 315 GNUNET_assert (GNUNET_OK == 316 GNUNET_log_setup ("test-cookie-access", 317 "ERROR", 318 NULL)); 319 GNUNET_CRYPTO_hash ("test-cookie-access", 320 strlen ("test-cookie-access"), 321 &paivana_secret); 322 hour = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS); 323 /* A fixed instant in the past (2023-11-14), so that the expiry cases 324 do not depend on how long the test takes to run. */ 325 past.abs_time = GNUNET_TIME_absolute_from_s (1700000000); 326 327 fprintf (stderr, 328 "-- a cookie grants what it was minted for --\n"); 329 val = mint_default (); 330 grants ("the cookie just minted", 331 val, 332 WEBSITE, 333 sizeof (ca4), 334 ca4, 335 true); 336 GNUNET_free (val); 337 338 fprintf (stderr, 339 "-- ...and expires when it says it does --\n"); 340 /* An hour is an ordinary access duration and, before `expiration' 341 was named that, an unreachable one: the pay endpoint refused any 342 value more than 90 seconds ahead, so this cookie could not be 343 obtained at all. */ 344 val = mint (WEBSITE, 345 hour, 346 sizeof (ca4), 347 ca4); 348 grants ("an expiration an hour out", 349 val, 350 WEBSITE, 351 sizeof (ca4), 352 ca4, 353 true); 354 GNUNET_free (val); 355 /* compute_cookie() raises a sub-second Max-Age to 1, since a Max-Age 356 of 0 would delete the cookie on arrival (RFC 6265 section 5.2.2). 357 That is a floor on the attribute, not on the access: the value 358 itself still carries the expiration, and this side must not honour 359 a second the other side rounded up to. */ 360 val = mint (WEBSITE, 361 past, 362 sizeof (ca4), 363 ca4); 364 grants ("an expiration in the past", 365 val, 366 WEBSITE, 367 sizeof (ca4), 368 ca4, 369 false); 370 GNUNET_free (val); 371 372 fprintf (stderr, 373 "-- the expiration is inside the MAC --\n"); 374 { 375 unsigned long long secs; 376 const char *dash; 377 char *forged; 378 379 /* The holder of a cookie can read its expiration off the value and 380 would like a later one. Rewriting it has to fail: the timestamp 381 is the KDF salt, so a value whose prefix no longer matches what 382 was signed cannot verify. */ 383 val = mint_default (); 384 GNUNET_assert (1 == 385 sscanf (val, 386 "%llu-", 387 &secs)); 388 dash = strchr (val, 389 '-'); 390 GNUNET_assert (NULL != dash); 391 GNUNET_asprintf (&forged, 392 "%llu%s", 393 secs + 365ULL * 24 * 60 * 60, 394 dash); 395 grants ("an expiration moved a year forward", 396 forged, 397 WEBSITE, 398 sizeof (ca4), 399 ca4, 400 false); 401 GNUNET_free (forged); 402 GNUNET_free (val); 403 } 404 405 fprintf (stderr, 406 "-- the website is inside the MAC --\n"); 407 val = mint_default (); 408 grants ("presented for a different site", 409 val, 410 "https://example.com/premium/other", 411 sizeof (ca4), 412 ca4, 413 false); 414 grants ("presented for a different host", 415 val, 416 "https://evil.example.net/premium/article", 417 sizeof (ca4), 418 ca4, 419 false); 420 grants ("presented for a different scheme", 421 val, 422 "http://example.com/premium/article", 423 sizeof (ca4), 424 ca4, 425 false); 426 GNUNET_free (val); 427 428 fprintf (stderr, 429 "-- the client address is inside the MAC --\n"); 430 /* This is what stops a paid cookie from being passed around, and it 431 is why get_client_address() has to be right about who the client 432 is: whoever the address says paid, paid for everyone at it. */ 433 val = mint_default (); 434 grants ("presented from a neighbouring address", 435 val, 436 WEBSITE, 437 sizeof (ca4b), 438 ca4b, 439 false); 440 grants ("presented from an address of a different family", 441 val, 442 WEBSITE, 443 sizeof (ca6), 444 ca6, 445 false); 446 grants ("presented from no address at all", 447 val, 448 WEBSITE, 449 0, 450 "", 451 false); 452 GNUNET_free (val); 453 /* An IPv6 client is not a special case, just a longer one. */ 454 val = mint (WEBSITE, 455 hour, 456 sizeof (ca6), 457 ca6); 458 grants ("an IPv6 cookie for its own address", 459 val, 460 WEBSITE, 461 sizeof (ca6), 462 ca6, 463 true); 464 grants ("an IPv6 cookie for a truncation of it", 465 val, 466 WEBSITE, 467 sizeof (ca4), 468 ca6, 469 false); 470 GNUNET_free (val); 471 472 fprintf (stderr, 473 "-- malformed values are refused, not parsed --\n"); 474 /* Every one of these is a string an attacker can put in a Cookie 475 header, and between them they take every exit check_cookie() has 476 before it gets as far as comparing MACs: no separator at all, a 477 seconds field that is not a bare decimal count ending at the 478 separator, and a hash that does not decode. */ 479 refuses ("the empty value", 480 ""); 481 refuses ("a value with no '-' at all", 482 "deadbeef"); 483 refuses ("a '-' with no seconds before it", 484 "-ABCDEF"); 485 refuses ("a non-numeric prefix", 486 "soon-ABCDEF"); 487 { 488 unsigned long long secs; 489 char *bad; 490 491 /* A well-formed, unexpired prefix followed by something that is 492 not the base32 encoding of a hash: the length check and the 493 alphabet check both live in string_to_data(). */ 494 secs = (unsigned long long) 495 (GNUNET_TIME_timestamp_to_s (hour)); 496 GNUNET_asprintf (&bad, 497 "%llu-", 498 secs); 499 refuses ("a prefix with an empty hash", 500 bad); 501 GNUNET_free (bad); 502 GNUNET_asprintf (&bad, 503 "%llu-!!!!!!!!", 504 secs); 505 refuses ("a hash outside the base32 alphabet", 506 bad); 507 GNUNET_free (bad); 508 GNUNET_asprintf (&bad, 509 "%llu-ABCDEFGH", 510 secs); 511 refuses ("a hash of the wrong length", 512 bad); 513 GNUNET_free (bad); 514 } 515 { 516 /* A cookie of ours, respelled. These are not malformed values in 517 the sense above -- every one of them decodes to the same seconds 518 and the same hash as a value we really issued, so a parser that 519 accepts them accepts a second, third and fourth spelling of one 520 live credential. That is what a bare `sscanf ("%llu-")' did: it 521 took a leading '+', leading whitespace and leading zeros, and its 522 trailing '-' verified nothing, so anything at all could sit 523 between the seconds and the separator. A cookie has exactly one 524 spelling or the set of strings that grant access is not the set 525 we minted. */ 526 char *good = mint_default (); 527 const char *gdash = strchr (good, 528 '-'); 529 char *bad; 530 531 GNUNET_assert (NULL != gdash); 532 GNUNET_asprintf (&bad, 533 "+%s", 534 good); 535 refuses ("a live cookie with a '+' before the seconds", 536 bad); 537 GNUNET_free (bad); 538 GNUNET_asprintf (&bad, 539 " %s", 540 good); 541 refuses ("a live cookie with a space before the seconds", 542 bad); 543 GNUNET_free (bad); 544 GNUNET_asprintf (&bad, 545 "0%s", 546 good); 547 refuses ("a live cookie with a leading zero on the seconds", 548 bad); 549 GNUNET_free (bad); 550 GNUNET_asprintf (&bad, 551 "%.*sx%s", 552 (int) (gdash - good), 553 good, 554 gdash); 555 refuses ("a live cookie with junk between seconds and '-'", 556 bad); 557 GNUNET_free (bad); 558 GNUNET_asprintf (&bad, 559 "99999999999999999999999%s", 560 gdash); 561 refuses ("a seconds count that does not fit", 562 bad); 563 GNUNET_free (bad); 564 GNUNET_free (good); 565 } 566 { 567 char *val2; 568 char *swapped; 569 const char *dash; 570 unsigned long long secs; 571 572 /* A syntactically perfect value whose hash simply is not ours: 573 the last exit, the one the MAC comparison itself takes. */ 574 val = mint_default (); 575 val2 = mint (WEBSITE, 576 hour, 577 sizeof (ca4b), 578 ca4b); 579 dash = strchr (val2, 580 '-'); 581 GNUNET_assert (NULL != dash); 582 GNUNET_assert (1 == 583 sscanf (val, 584 "%llu-", 585 &secs)); 586 GNUNET_asprintf (&swapped, 587 "%llu%s", 588 secs, 589 dash); 590 refuses ("another client's hash under our own timestamp", 591 swapped); 592 GNUNET_free (swapped); 593 GNUNET_free (val2); 594 GNUNET_free (val); 595 } 596 597 fprintf (stderr, 598 "-- -g scopes the cookie to the whole deployment --\n"); 599 /* With PH_global_cookie the website drops out of the KDF, so one 600 cookie is good everywhere paivana serves. That is the point of 601 the switch; what matters is that it is symmetric, i.e. that 602 flipping it invalidates the cookies minted under the other 603 setting rather than silently widening or narrowing access. */ 604 PH_global_cookie = 1; 605 val = mint (WEBSITE, 606 hour, 607 sizeof (ca4), 608 ca4); 609 grants ("-g: a cookie minted elsewhere on the site", 610 val, 611 "https://example.com/other/article", 612 sizeof (ca4), 613 ca4, 614 true); 615 grants ("-g: still bound to the client address", 616 val, 617 "https://example.com/other/article", 618 sizeof (ca4b), 619 ca4b, 620 false); 621 PH_global_cookie = 0; 622 grants ("-g cookie presented after -g was turned off", 623 val, 624 WEBSITE, 625 sizeof (ca4), 626 ca4, 627 false); 628 GNUNET_free (val); 629 val = mint (WEBSITE, 630 hour, 631 sizeof (ca4), 632 ca4); 633 PH_global_cookie = 1; 634 grants ("non-global cookie presented after -g was turned on", 635 val, 636 WEBSITE, 637 sizeof (ca4), 638 ca4, 639 false); 640 PH_global_cookie = 0; 641 GNUNET_free (val); 642 643 fprintf (stderr, 644 "-- the paivana_id --\n"); 645 { 646 struct PAIVANA_Nonce nonce; 647 struct PAIVANA_Nonce nonce2; 648 static const uint8_t nonce_bytes[16] = { 649 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 650 }; 651 /* Computed independently from the same definition the paywall page 652 implements (src/frontend/paywall.js makePaivanaId()): 653 base64url (SHA-256 (nonce || website || '\0' || be64 (usec))) 654 with the padding stripped, prefixed by the expiration in seconds. 655 Both ends derive this from their own copy of the inputs and never 656 exchange it, so the two implementations agreeing is the whole 657 protocol: a change on either side that this vector does not 658 survive means every order is created under an id the other side 659 will not look for. Note that the client hashes the expiration, 660 which is why the pay endpoint cannot re-derive it and has to take 661 the client's word for it (bounded by the contract). */ 662 static const char *want = 663 "1700000000-gAGOp3pVpQq9VTA7P0cYqqKTiFGLxC_9FLZZXkmZwGc"; 664 char *id; 665 char *id2; 666 667 /* memcpy rather than an initialiser list: the nonce is hashed as 668 the bytes it is in memory, and a `uint32_t val[4]' written as 669 integers would give a different digest on a big-endian host. */ 670 memcpy (&nonce, 671 nonce_bytes, 672 sizeof (nonce)); 673 id = PAIVANA_HTTPD_compute_paivana_id (past, 674 WEBSITE, 675 &nonce); 676 if (0 != strcmp (id, 677 want)) 678 { 679 fprintf (stderr, 680 "FAIL: paivana_id is `%s', want `%s'\n", 681 id, 682 want); 683 failures++; 684 } 685 else 686 { 687 fprintf (stderr, 688 " ok: paivana_id matches the paywall page's derivation\n"); 689 } 690 id_is_wellformed ("the shape of a paivana_id", 691 id); 692 693 /* All three inputs have to reach the digest. */ 694 id2 = PAIVANA_HTTPD_compute_paivana_id (hour, 695 WEBSITE, 696 &nonce); 697 differ ("a different expiration gives a different id", 698 id, 699 id2); 700 GNUNET_free (id2); 701 id2 = PAIVANA_HTTPD_compute_paivana_id (past, 702 "https://example.com/premium/other", 703 &nonce); 704 differ ("a different website gives a different id", 705 id, 706 id2); 707 GNUNET_free (id2); 708 memcpy (&nonce2, 709 nonce_bytes, 710 sizeof (nonce2)); 711 nonce2.val[3] ^= 1; 712 id2 = PAIVANA_HTTPD_compute_paivana_id (past, 713 WEBSITE, 714 &nonce2); 715 differ ("a different nonce gives a different id", 716 id, 717 id2); 718 GNUNET_free (id2); 719 /* The whole website string reaches the digest, terminating NUL 720 included -- not some prefix of it. */ 721 id2 = PAIVANA_HTTPD_compute_paivana_id (past, 722 WEBSITE "x", 723 &nonce); 724 differ ("a website that extends another gives a different id", 725 id, 726 id2); 727 GNUNET_free (id2); 728 /* ...and it is a pure function, or the order and the redemption 729 would never line up. */ 730 id2 = PAIVANA_HTTPD_compute_paivana_id (past, 731 WEBSITE, 732 &nonce); 733 if (0 != strcmp (id, 734 id2)) 735 { 736 fprintf (stderr, 737 "FAIL: paivana_id is not deterministic: `%s' then `%s'\n", 738 id, 739 id2); 740 failures++; 741 } 742 else 743 { 744 fprintf (stderr, 745 " ok: paivana_id is deterministic\n"); 746 } 747 GNUNET_free (id2); 748 GNUNET_free (id); 749 } 750 751 if (0 != failures) 752 { 753 fprintf (stderr, 754 "%u check(s) failed\n", 755 failures); 756 return 1; 757 } 758 fprintf (stderr, 759 "all checks passed\n"); 760 return 0; 761 }