test_paywall.sh (28223B)
1 #!/usr/bin/env bash 2 # This file is part of GNU Taler 3 # Copyright (C) 2025, 2026 Taler Systems SA 4 # 5 # GNU Taler is free software; you can redistribute it and/or modify it under 6 # the terms of the GNU Affero General Public License as published by the Free 7 # Software Foundation; either version 3, or (at your option) any later 8 # version. 9 # 10 # GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 12 # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for 13 # more details. 14 # 15 # You should have received a copy of the GNU Affero General Public License 16 # along with GNU Taler; see the file COPYING. If not, see 17 # <http://www.gnu.org/licenses/> 18 # 19 # 20 # The paywall, against a real merchant backend. 21 # 22 # test_reverse_proxy.sh runs paivana-httpd with `-n', which switches the 23 # paywall off before the first byte of a request is looked at, so it 24 # exercises the proxy and nothing else. Everything paivana exists for -- 25 # the paywall page, the payment, the access cookie -- is on the other side 26 # of that flag and had no automated coverage at all. 27 # 28 # This test puts a real GNU Taler system behind paivana-httpd with 29 # taler-unified-setup.sh (a fakebank, an exchange and a merchant backend, 30 # the same way merchant and anastasis test their own payment paths), buys 31 # access with taler-wallet-cli, and checks what the daemon does with it. 32 # 33 # The client half is written out by hand rather than driven through a 34 # browser: the paywall page's JavaScript computes a payment identifier from 35 # (nonce, website, expiration) and the daemon computes the same identifier 36 # independently, so the two agreeing IS the protocol -- it is never sent 37 # from one to the other. paivana_id.py re-derives it from the definition 38 # src/frontend/paywall.js implements, which is what makes this a test of 39 # both ends rather than of one end twice. 40 41 set -eu 42 43 # shellcheck source=src/tests/setup.sh 44 . "$(cd -- "$(dirname -- "$0")" && pwd)/setup.sh" 45 46 # Ports of the Taler system come from test_paywall.conf and are fixed; 47 # paivana's own move as a block, exactly as in test_reverse_proxy.sh. 48 PORT_BASE="${PAIVANA_PORT_BASE:-18400}" 49 PAIVANA_PORT=$((PORT_BASE + 110)) 50 ORIGIN_PORT=$((PORT_BASE + 111)) 51 MERCHANT_PORT=9966 52 EXCHANGE_PORT=8081 53 BANK_PORT=8082 54 55 function here() { 56 cd -- "$(dirname -- "$0")" && pwd 57 } 58 59 # Same convention as test_reverse_proxy.sh: meson passes both, and the 60 # defaults let the script be run by hand from either tree. 61 SRCDIR="${SRCDIR:-$(here)}" 62 BUILDDIR="${BUILDDIR:-$PWD}" 63 64 PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}" 65 UPSTREAM_MHD="${UPSTREAM_MHD:-$BUILDDIR/upstream_mhd}" 66 67 CHECKS=0 68 FAILS=0 69 70 function ok() { 71 CHECKS=$((CHECKS + 1)) 72 echo " OK: $1" 73 } 74 75 function fail() { 76 CHECKS=$((CHECKS + 1)) 77 FAILS=$((FAILS + 1)) 78 printf ' FAIL: %b\n' "$1" >&2 79 } 80 81 # Is a TCP port free? Same probe as test_reverse_proxy.sh: bash's 82 # /dev/tcp in a subshell, so a failed connect cannot kill the script. 83 function port_is_free() { 84 ! (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null 85 } 86 87 function wait_for_port() { 88 local port="$1" pid="${2:-}" tries=100 89 while [ "$tries" -gt 0 ] 90 do 91 if ! port_is_free "$port" 92 then 93 return 0 94 fi 95 # Probe first: a child that handed the listening socket on and 96 # exited is still a service that came up. 97 if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null 98 then 99 return 1 100 fi 101 sleep 0.1 102 tries=$((tries - 1)) 103 done 104 return 1 105 } 106 107 # -------------------------------------------------------------------- 108 # Preconditions. Every one of these is an environment problem rather 109 # than a regression, so each exits 77 naming what is missing. 110 # -------------------------------------------------------------------- 111 112 echo -n "Checking for curl ..." 113 curl --version >/dev/null 2>&1 || exit_skip "no curl" 114 echo " OK" 115 echo -n "Checking for jq ..." 116 jq --version >/dev/null 2>&1 || exit_skip "no jq" 117 echo " OK" 118 echo -n "Checking for python3 ..." 119 python3 --version >/dev/null 2>&1 || exit_skip "no python3" 120 echo " OK" 121 echo -n "Checking for taler-unified-setup.sh ..." 122 command -v taler-unified-setup.sh >/dev/null 2>&1 \ 123 || exit_skip "no taler-unified-setup.sh (the Taler stack is not installed)" 124 echo " OK" 125 echo -n "Checking for taler-wallet-cli ..." 126 command -v taler-wallet-cli >/dev/null 2>&1 || exit_skip "no taler-wallet-cli" 127 echo " OK" 128 echo -n "Checking for taler-merchant-httpd ..." 129 command -v taler-merchant-httpd >/dev/null 2>&1 || exit_skip "no taler-merchant-httpd" 130 echo " OK" 131 echo -n "Checking for PostgreSQL ..." 132 pg_isready >/dev/null 2>&1 || exit_skip "PostgreSQL is not accepting connections" 133 echo " OK" 134 echo -n "Checking for paivana-httpd ..." 135 [ -x "$PAIVANA_HTTPD" ] || exit_skip "no paivana-httpd at $PAIVANA_HTTPD" 136 echo " OK" 137 echo -n "Checking for the test upstream ..." 138 [ -x "$UPSTREAM_MHD" ] || exit_skip "no upstream_mhd at $UPSTREAM_MHD" 139 echo " OK" 140 141 # The paywall page is a template paivana loads from its installation 142 # prefix, so a build tree alone is not enough. Rather than demanding 143 # `make install', stage the one file into a prefix of our own and point 144 # PAIVANA_PREFIX at it -- the same variable paivana's project data 145 # declares for exactly this. 146 PAYWALL_TEMPLATE="" 147 for cand in "$BUILDDIR/../frontend/paywall.en.must" \ 148 "$SRCDIR/../frontend/paywall.en.must" 149 do 150 if [ -r "$cand" ] 151 then 152 PAYWALL_TEMPLATE="$cand" 153 break 154 fi 155 done 156 if [ -z "$PAYWALL_TEMPLATE" ] 157 then 158 exit_skip "the paywall template has not been built" 159 fi 160 161 echo -n "Checking that the ports are free ..." 162 BUSY="" 163 for p in "$PAIVANA_PORT" "$ORIGIN_PORT" "$MERCHANT_PORT" "$EXCHANGE_PORT" "$BANK_PORT" 164 do 165 port_is_free "$p" || BUSY="$BUSY $p" 166 done 167 if [ -n "$BUSY" ] 168 then 169 # The Taler ports are the ones the merchant suite uses, so this is 170 # most often another Taler test running, not a leftover. paivana's 171 # own two move with PAIVANA_PORT_BASE. 172 exit_skip "port(s) already in use:$BUSY" 173 fi 174 echo " OK" 175 176 # -------------------------------------------------------------------- 177 # Scratch space. NOT named TMPDIR: that is the variable every child 178 # reads for its own temporary files, and cleanup removes this directory 179 # while the Taler services may still be running out of it. 180 # -------------------------------------------------------------------- 181 182 SCRATCH="$(mktemp -d -t paivana-paywall.XXXXXX)" 183 PIDS="" 184 185 function stop_children() { 186 local pid 187 for pid in $PIDS 188 do 189 kill -TERM "$pid" 2>/dev/null || true 190 done 191 for pid in $PIDS 192 do 193 wait "$pid" 2>/dev/null || true 194 done 195 PIDS="" 196 } 197 198 function paywall_cleanup() { 199 stop_children 200 # exit_cleanup (from setup.sh) stops taler-unified-setup.sh. 201 exit_cleanup 202 if [ -z "${KEEP_TMP:-}" ] 203 then 204 rm -rf "$SCRATCH" 205 else 206 echo "Keeping $SCRATCH" >&2 207 fi 208 } 209 trap paywall_cleanup EXIT 210 211 mkdir -p "$SCRATCH/prefix/share/paivana/templates" 212 cp "$PAYWALL_TEMPLATE" "$SCRATCH/prefix/share/paivana/templates/" 213 export PAIVANA_PREFIX="$SCRATCH/prefix/" 214 215 # -------------------------------------------------------------------- 216 # The Taler system. 217 # -------------------------------------------------------------------- 218 219 CONF="$SRCDIR/test_paywall.conf" 220 [ -r "$CONF" ] || exit_fail "cannot read $CONF" 221 222 # taler-unified-setup.sh resolves TALER_TEST_HOME relative to the working 223 # directory and writes into it, so run it from the scratch directory with 224 # an absolute path to the configuration. 225 CONF="$(cd "$(dirname "$CONF")" && pwd)/$(basename "$CONF")" 226 cd "$SCRATCH" 227 228 # Give the exchange and the merchant a database of their own. Reusing 229 # `talercheck' would have this test and the merchant's own tests 230 # clobbering each other's tables. 231 DB=paivanacheck 232 if ! psql -Aqt -c "SELECT 1" "$DB" >/dev/null 2>&1 233 then 234 createdb "$DB" >/dev/null 2>&1 \ 235 || exit_skip "cannot create the PostgreSQL database $DB" 236 fi 237 238 setup -c "$CONF" \ 239 -r "merchant-exchange-default" \ 240 -em \ 241 -f -d x-taler-bank -u exchange-account-2 242 243 MERCHANT_URL="http://localhost:$MERCHANT_PORT/" 244 AUTH="Authorization: Bearer secret-token:super_secret" 245 RESP="$SCRATCH/response.json" 246 247 function merchant_post() { 248 local path="$1" body="$2" 249 curl -s -o "$RESP" -w "%{http_code}" \ 250 -H "Content-Type: application/json" \ 251 -H "$AUTH" \ 252 -X POST \ 253 "${MERCHANT_URL}${path}" \ 254 -d "$body" 255 } 256 257 echo -n "Configuring merchant instance ..." 258 STATUS=$(merchant_post management/instances \ 259 '{"auth":{"method":"token","token":"secret-token:super_secret"}, 260 "id":"admin","name":"default","user_type":"business", 261 "address":{},"jurisdiction":{},"use_stefan":true, 262 "default_wire_transfer_delay":{"d_us":50000000000}, 263 "default_pay_delay":{"d_us":60000000000}}') 264 [ "$STATUS" = "204" ] || exit_fail "creating the instance: got $STATUS, $(cat "$RESP")" 265 echo " OK" 266 267 echo -n "Configuring merchant bank account ..." 268 STATUS=$(merchant_post private/accounts \ 269 '{"payto_uri":"payto://x-taler-bank/localhost/fortythree?receiver-name=fortythree"}') 270 [ "$STATUS" = "200" ] || exit_fail "adding the account: got $STATUS, $(cat "$RESP")" 271 echo " OK" 272 273 # website_regex is `.*' so that every URL is paywalled and WHITELIST is 274 # the only exemption; that is what lets the whitelist cases below mean 275 # what they say. With a narrower expression a URL matching no template 276 # is served for free, which would make a whitelist miss indistinguishable 277 # from a template miss. 278 echo -n "Creating the Paivana template ..." 279 STATUS=$(merchant_post private/templates \ 280 '{"template_id":"premium", 281 "template_description":"Paywalled content", 282 "template_contract":{"template_type":"paivana", 283 "summary":"Access to the article", 284 "website_regex":".*", 285 "max_pickup_duration":{"d_us":3600000000}, 286 "choices":[{"amount":"TESTKUDOS:1"}]}}') 287 [ "$STATUS" = "204" ] || exit_fail "creating the template: got $STATUS, $(cat "$RESP")" 288 echo " OK" 289 290 # -------------------------------------------------------------------- 291 # The origin and paivana itself. 292 # -------------------------------------------------------------------- 293 294 BASE_URL="http://localhost:$PAIVANA_PORT/" 295 296 cat > "$SCRATCH/paivana.conf" <<EOF 297 [paivana] 298 DESTINATION_BASE_URL = http://localhost:$ORIGIN_PORT/ 299 BASE_URL = $BASE_URL 300 MERCHANT_BACKEND_URL = $MERCHANT_URL 301 MERCHANT_ACCESS_TOKEN = secret-token:super_secret 302 # A fixed secret so that a cookie minted here can be reasoned about; a 303 # real deployment must not do this (see the manual on SECRET). 304 SECRET = paivana-integration-test-secret 305 WHITELIST = /echo-headers|/large/.* 306 SERVE = tcp 307 PORT = $PAIVANA_PORT 308 EOF 309 310 echo -n "Starting the origin ..." 311 "$UPSTREAM_MHD" "$ORIGIN_PORT" > "$SCRATCH/origin.log" 2>&1 & 312 ORIGIN_PID=$! 313 PIDS="$PIDS $ORIGIN_PID" 314 wait_for_port "$ORIGIN_PORT" "$ORIGIN_PID" \ 315 || exit_fail "the test origin did not start on port $ORIGIN_PORT" 316 echo " OK" 317 318 echo -n "Starting paivana-httpd ..." 319 "$PAIVANA_HTTPD" -c "$SCRATCH/paivana.conf" -L INFO > "$SCRATCH/paivana.log" 2>&1 & 320 PAIVANA_PID=$! 321 PIDS="$PIDS $PAIVANA_PID" 322 # paivana does not listen until it has fetched the templates, so this 323 # also asserts that the merchant handed them over. 324 wait_for_port "$PAIVANA_PORT" "$PAIVANA_PID" \ 325 || exit_fail "paivana-httpd did not start:\n$(cat "$SCRATCH/paivana.log")" 326 echo " OK" 327 328 # -------------------------------------------------------------------- 329 # What the paywall does before anyone has paid. 330 # -------------------------------------------------------------------- 331 332 echo "-- before payment --" 333 334 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}hello") 335 if [ "$STATUS" = "302" ] 336 then 337 ok "an unpaid URL is redirected to the paywall" 338 else 339 fail "an unpaid URL should be 302, got $STATUS" 340 fi 341 342 curl -s -D "$SCRATCH/redirect.hdr" -o /dev/null "${BASE_URL}hello" 343 LOCATION=$(grep -i '^location:' "$SCRATCH/redirect.hdr" | tr -d '\r' | cut -d' ' -f2-) 344 case "$LOCATION" in 345 *"/.well-known/paivana/templates/premium#"*) 346 ok "the redirect names the template and carries the website in the fragment" 347 ;; 348 *) 349 fail "unexpected paywall redirect: $LOCATION" 350 ;; 351 esac 352 353 # The fragment is the website, base64url-encoded; the page's JavaScript 354 # atob()s it, so a '-' or '_' in it has to survive. 355 FRAGMENT="${LOCATION#*#}" 356 DECODED=$(python3 -c ' 357 import base64, sys 358 s = sys.argv[1] 359 print(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)).decode("utf-8"))' "$FRAGMENT") 360 if [ "$DECODED" = "${BASE_URL}hello" ] 361 then 362 ok "the fragment decodes to the URL that was asked for" 363 else 364 fail "fragment decodes to '$DECODED', want '${BASE_URL}hello'" 365 fi 366 367 PAGE_STATUS=$(curl -s -D "$SCRATCH/page.hdr" -o "$SCRATCH/page.html" \ 368 -w "%{http_code}" \ 369 "${BASE_URL}.well-known/paivana/templates/premium") 370 if [ "$PAGE_STATUS" = "402" ] 371 then 372 ok "the paywall page is served with 402 Payment Required" 373 else 374 fail "the paywall page should be 402, got $PAGE_STATUS" 375 fi 376 377 PAIVANA_HEADER=$(grep -i '^paivana:' "$SCRATCH/page.hdr" | tr -d '\r' | cut -d' ' -f2-) 378 case "$PAIVANA_HEADER" in 379 "taler+http://pay-template/localhost:$MERCHANT_PORT/premium") 380 ok "the Paivana header carries the pay-template URI" 381 ;; 382 *) 383 fail "unexpected Paivana header: '$PAIVANA_HEADER'" 384 ;; 385 esac 386 387 if grep -qi "^content-security-policy:.*default-src 'none'" "$SCRATCH/page.hdr" 388 then 389 ok "the paywall page carries a Content-Security-Policy" 390 else 391 fail "the paywall page has no restrictive CSP" 392 fi 393 394 # -------------------------------------------------------------------- 395 # The whitelist, which only exists on this side of the paywall. 396 # 397 # README and paivana.conf(5) both promise that a WHITELIST expression is 398 # matched against the whole path and anchored at both ends, so that 399 # `/free/' waives nothing while `/free/.*' waives a subtree. The 400 # regexec that decides it sits behind the paywall `-n' switches off, so 401 # no other test in the tree can reach these. 402 # -------------------------------------------------------------------- 403 404 echo "-- the whitelist --" 405 406 function expect_forwarded() { 407 local path="$1" what="$2" status 408 status=$(curl -s -D "$SCRATCH/w.hdr" -o /dev/null -w "%{http_code}" "${BASE_URL}${path}") 409 if [ "302" = "$status" ] 410 then 411 fail "$what: /$path was paywalled (302)" 412 elif grep -qi '^x-upstream:' "$SCRATCH/w.hdr" 413 then 414 ok "$what" 415 else 416 fail "$what: /$path answered $status but not by the origin" 417 fi 418 } 419 420 function expect_paywalled() { 421 local path="$1" what="$2" status 422 status=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}${path}") 423 if [ "302" = "$status" ] 424 then 425 ok "$what" 426 else 427 fail "$what: /$path answered $status, want 302" 428 fi 429 } 430 431 expect_forwarded "echo-headers" "a whitelisted path is served without payment" 432 expect_forwarded "large/64" "an alternation branch is whitelisted too" 433 expect_paywalled "x/echo-headers" \ 434 "a path merely containing the whitelisted one is not waived (left anchor)" 435 expect_paywalled "echo-headers/x" \ 436 "a path extending the whitelisted one is not waived (right anchor)" 437 expect_paywalled "hello" "a path outside the whitelist is paywalled" 438 439 # -------------------------------------------------------------------- 440 # The payment endpoint's refusals, before we have anything valid to send. 441 # -------------------------------------------------------------------- 442 443 echo "-- the payment endpoint --" 444 445 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ 446 -H "Content-Type: application/json" \ 447 "${BASE_URL}.well-known/paivana" -d '{}') 448 if [ "$STATUS" = "400" ] 449 then 450 ok "a redemption without the required fields is refused with 400" 451 else 452 fail "an empty redemption should be 400, got $STATUS" 453 fi 454 455 # A nonce is 16 bytes in GNUnet's Crockford base32, which is 26 456 # characters; anything else must not reach the order lookup. 25 zeroes 457 # is the off-by-one an implementer would write. 458 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ 459 -H "Content-Type: application/json" \ 460 "${BASE_URL}.well-known/paivana" \ 461 -d "$(jq -n --arg w "${BASE_URL}hello" \ 462 '{order_id:"no-such-order",nonce:"0000000000000000000000000", 463 expiration:{t_s:2000000000},website:$w}')") 464 if [ "$STATUS" = "400" ] 465 then 466 ok "a redemption with a nonce of the wrong length is refused with 400" 467 else 468 fail "a short nonce should be 400, got $STATUS" 469 fi 470 471 # Well-formed, but naming an order the merchant has never heard of: this 472 # has to reach the backend and come back as a refusal rather than as a 473 # parse error, which is what tells the two apart in a log. 474 read -r JUNK_NONCE _ < <(python3 "$SRCDIR/paivana_id.py" 2000000000 "${BASE_URL}hello" \ 475 000102030405060708090a0b0c0d0e0f) 476 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ 477 -H "Content-Type: application/json" \ 478 "${BASE_URL}.well-known/paivana" \ 479 -d "$(jq -n --arg w "${BASE_URL}hello" --arg n "$JUNK_NONCE" \ 480 '{order_id:"no-such-order",nonce:$n, 481 expiration:{t_s:2000000000},website:$w}')") 482 if [ "$STATUS" = "404" ] || [ "$STATUS" = "409" ] 483 then 484 ok "a redemption naming an order that was never paid is refused ($STATUS)" 485 else 486 fail "an unknown order should be 404 or 409, got $STATUS" 487 fi 488 489 # -------------------------------------------------------------------- 490 # Buy access. 491 # -------------------------------------------------------------------- 492 493 echo "-- paying --" 494 495 WALLET="$SCRATCH/wallet.sqlite3" 496 497 echo -n "Withdrawing test coins ..." 498 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \ 499 api --expect-success 'withdrawTestBalance' \ 500 "$(jq -n --arg bank "http://localhost:$BANK_PORT/" \ 501 --arg exchange "http://localhost:$EXCHANGE_PORT/" \ 502 '{amount:"TESTKUDOS:20",corebankApiBaseUrl:$bank,exchangeBaseUrl:$exchange}')" \ 503 > "$SCRATCH/withdraw.out" 2> "$SCRATCH/withdraw.err" \ 504 || exit_fail "withdrawTestBalance failed:\n$(cat "$SCRATCH/withdraw.err")" 505 taler-exchange-wirewatch -a exchange-account-2 -c "$CONF" -t \ 506 > "$SCRATCH/wirewatch.log" 2>&1 \ 507 || exit_fail "wirewatch failed:\n$(cat "$SCRATCH/wirewatch.log")" 508 timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" run-until-done \ 509 > "$SCRATCH/withdraw2.out" 2> "$SCRATCH/withdraw2.err" \ 510 || exit_fail "the withdrawal did not finish:\n$(cat "$SCRATCH/withdraw2.err")" 511 echo " OK" 512 513 WEBSITE="${BASE_URL}hello" 514 # The page computes this as `now + max_pickup_duration'; one hour is what 515 # the template above allows. 516 EXPIRATION=$(( $(date +%s) + 3600 )) 517 read -r NONCE PAIVANA_ID < <(python3 "$SRCDIR/paivana_id.py" "$EXPIRATION" "$WEBSITE") 518 echo " paivana_id: $PAIVANA_ID" 519 520 function urlenc() { 521 python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1" 522 } 523 524 PAY_URI="taler+http://pay-template/localhost:$MERCHANT_PORT/premium" 525 PAY_URI="$PAY_URI?session_id=$(urlenc "$PAIVANA_ID")" 526 PAY_URI="$PAY_URI&fulfillment_url=$(urlenc "$WEBSITE")" 527 528 echo -n "Paying ..." 529 # "0" selects the first (and only) choice. 530 echo "0" | timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \ 531 handle-uri "$PAY_URI" -y > "$SCRATCH/pay.out" 2> "$SCRATCH/pay.err" \ 532 || exit_fail "paying failed:\n$(cat "$SCRATCH/pay.err")" 533 echo " OK" 534 535 # This is what the paywall page polls: the merchant reports the order it 536 # has seen paid under our session, which is the paivana ID. 537 SESSION_URL="${MERCHANT_URL}sessions/$(urlenc "$PAIVANA_ID")" 538 SESSION_URL="$SESSION_URL?fulfillment_url=$(urlenc "$WEBSITE")&timeout_ms=10000" 539 STATUS=$(curl -s -o "$RESP" -w "%{http_code}" "$SESSION_URL") 540 ORDER_ID=$(jq -r '.order_id // empty' < "$RESP") 541 if [ "$STATUS" = "200" ] && [ -n "$ORDER_ID" ] 542 then 543 ok "the merchant reports the order paid under the paivana ID we derived" 544 else 545 fail "the merchant did not report a paid order: $STATUS, $(cat "$RESP")" 546 echo "=== $CHECKS checks, $FAILS failures ===" 547 exit 1 548 fi 549 550 # -------------------------------------------------------------------- 551 # Redeem it. 552 # -------------------------------------------------------------------- 553 554 echo "-- redeeming --" 555 556 function redeem() { 557 local website="$1" expiration="$2" nonce="$3" order="$4" hdr="$5" 558 curl -s -D "$hdr" -o /dev/null -w "%{http_code}" -X POST \ 559 -H "Content-Type: application/json" \ 560 "${BASE_URL}.well-known/paivana" \ 561 -d "$(jq -n --arg o "$order" --arg n "$nonce" \ 562 --argjson e "$expiration" --arg w "$website" \ 563 '{order_id:$o,nonce:$n,expiration:{t_s:$e},website:$w}')" 564 } 565 566 STATUS=$(redeem "$WEBSITE" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/redeem.hdr") 567 if [ "$STATUS" = "303" ] 568 then 569 ok "a paid order is redeemed with 303" 570 else 571 fail "redemption should be 303, got $STATUS" 572 fi 573 574 COOKIE=$(grep -i '^set-cookie:' "$SCRATCH/redeem.hdr" | tr -d '\r' \ 575 | sed -n 's/.*\(Paivana-Cookie=[^;]*\).*/\1/p') 576 if [ -n "$COOKIE" ] 577 then 578 ok "the redemption sets an access cookie" 579 else 580 fail "no Paivana-Cookie in the redemption response" 581 fi 582 583 if grep -qi '^set-cookie:.*HttpOnly' "$SCRATCH/redeem.hdr" 584 then 585 ok "the access cookie is HttpOnly" 586 else 587 fail "the access cookie is not HttpOnly" 588 fi 589 590 REDIRECT=$(grep -i '^location:' "$SCRATCH/redeem.hdr" | tr -d '\r' | cut -d' ' -f2-) 591 if [ "$REDIRECT" = "$WEBSITE" ] 592 then 593 ok "the redemption redirects back to the paid URL" 594 else 595 fail "redemption redirects to '$REDIRECT', want '$WEBSITE'" 596 fi 597 598 # Nothing the client sends is taken on trust. Naming a different website 599 # changes the paivana ID -- it is derived from the website -- so the 600 # merchant is asked about a session it has never seen paid. 601 STATUS=$(redeem "${BASE_URL}item" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/x.hdr") 602 if [ "$STATUS" = "303" ] 603 then 604 fail "the order was redeemed for a URL it did not pay for" 605 else 606 ok "another website derives another paivana ID, so the order is not found ($STATUS)" 607 fi 608 609 # That check passes because of the session binding, and would pass even 610 # if check_contract() never looked at the contract. This one reaches the 611 # fulfillment-URL comparison itself: buy an order whose session IS the one 612 # we will claim, but whose fulfillment URL is somewhere else entirely. 613 # The merchant will sell it -- website_regex is `.*' -- and the session 614 # lookup will succeed, so the only thing standing between this and a 615 # cookie for a page nobody paid for is paivana comparing the contract's 616 # fulfillment URL against the website being claimed. 617 echo "-- an order bought for somewhere else --" 618 619 TARGET="${BASE_URL}item" 620 ELSEWHERE="${BASE_URL}elsewhere" 621 EXP2=$(( $(date +%s) + 3600 )) 622 read -r NONCE2 PAIVANA_ID2 < <(python3 "$SRCDIR/paivana_id.py" "$EXP2" "$TARGET") 623 624 STATUS=$(curl -s -o "$RESP" -w "%{http_code}" \ 625 -H "Content-Type: application/json" \ 626 -X POST "${MERCHANT_URL}templates/premium" \ 627 -d "$(jq -n --arg w "$ELSEWHERE" --arg s "$PAIVANA_ID2" \ 628 '{template_type:"paivana",website:$w,paivana_id:$s}')") 629 if [ "$STATUS" != "200" ] 630 then 631 fail "could not create the mismatched order: $STATUS, $(cat "$RESP")" 632 else 633 ORDER2=$(jq -r '.order_id' < "$RESP") 634 STATUS=$(curl -s -o "$RESP" -w "%{http_code}" -H "$AUTH" \ 635 "${MERCHANT_URL}private/orders/${ORDER2}?session_id=$(urlenc "$PAIVANA_ID2")") 636 PAY2=$(jq -r '.taler_pay_uri // empty' < "$RESP") 637 if [ -z "$PAY2" ] 638 then 639 fail "no pay URI for the mismatched order: $STATUS, $(cat "$RESP")" 640 else 641 echo "0" | timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \ 642 handle-uri "$PAY2" -y > "$SCRATCH/pay2.out" 2> "$SCRATCH/pay2.err" \ 643 || fail "paying the mismatched order failed:\n$(cat "$SCRATCH/pay2.err")" 644 645 # Confirm the trap is armed: the merchant really does report this 646 # order as paid under the session we are about to claim. 647 STATUS=$(curl -s -o "$RESP" -w "%{http_code}" \ 648 "${MERCHANT_URL}sessions/$(urlenc "$PAIVANA_ID2")?fulfillment_url=$(urlenc "$ELSEWHERE")&timeout_ms=10000") 649 if [ "$STATUS" = "200" ] && [ -n "$(jq -r '.order_id // empty' < "$RESP")" ] 650 then 651 ok "the mismatched order is paid and bound to the session we will claim" 652 else 653 fail "the mismatched order was not paid: $STATUS, $(cat "$RESP")" 654 fi 655 656 STATUS=$(redeem "$TARGET" "$EXP2" "$NONCE2" "$ORDER2" "$SCRATCH/mm.hdr") 657 if [ "$STATUS" = "303" ] 658 then 659 fail "an order bought for $ELSEWHERE minted a cookie for $TARGET" 660 else 661 ok "an order whose contract names another URL is refused ($STATUS)" 662 fi 663 664 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$TARGET") 665 if [ "$STATUS" = "302" ] 666 then 667 ok "and that URL is still paywalled afterwards" 668 else 669 fail "$TARGET answered $STATUS after the refused redemption" 670 fi 671 fi 672 fi 673 674 # The expiration is hashed into the paivana ID, so asking for a longer one 675 # than was paid for cannot match the session the merchant knows about. 676 STATUS=$(redeem "$WEBSITE" $((EXPIRATION + 86400)) "$NONCE" "$ORDER_ID" "$SCRATCH/x.hdr") 677 if [ "$STATUS" = "303" ] 678 then 679 fail "the order was redeemed for a later expiration than it paid for" 680 else 681 ok "redeeming for a later expiration is refused ($STATUS)" 682 fi 683 684 # -------------------------------------------------------------------- 685 # What the cookie is worth. 686 # -------------------------------------------------------------------- 687 688 echo "-- after payment --" 689 690 STATUS=$(curl -s -D "$SCRATCH/paid.hdr" -o "$SCRATCH/paid.body" \ 691 -w "%{http_code}" -H "Cookie: $COOKIE" "$WEBSITE") 692 if [ "$STATUS" = "200" ] && grep -qi '^x-upstream:' "$SCRATCH/paid.hdr" 693 then 694 ok "the paid URL is served from the origin" 695 else 696 fail "the paid URL answered $STATUS, and not from the origin" 697 fi 698 699 if grep -q "Hello" "$SCRATCH/paid.body" 700 then 701 ok "the origin's body arrives intact" 702 else 703 fail "the origin's body is not what it serves directly" 704 fi 705 706 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Cookie: $COOKIE" "${BASE_URL}item") 707 if [ "$STATUS" = "302" ] 708 then 709 ok "the cookie does not open a URL it was not minted for" 710 else 711 fail "the cookie opened /item as well: $STATUS" 712 fi 713 714 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$WEBSITE") 715 if [ "$STATUS" = "302" ] 716 then 717 ok "without the cookie the same URL is still paywalled" 718 else 719 fail "the paid URL is open to everyone: $STATUS" 720 fi 721 722 # The expiration is the KDF salt, so rewriting it in the value has to 723 # break the MAC rather than extend the access. 724 LATER=$(( EXPIRATION + 86400 )) 725 TAMPERED="Paivana-Cookie=${LATER}-${COOKIE#*-}" 726 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Cookie: $TAMPERED" "$WEBSITE") 727 if [ "$STATUS" = "302" ] 728 then 729 ok "rewriting the expiration in the cookie invalidates it" 730 else 731 fail "a cookie with a rewritten expiration was accepted: $STATUS" 732 fi 733 734 STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ 735 -H "Cookie: Paivana-Cookie=not-a-cookie" "$WEBSITE") 736 if [ "$STATUS" = "302" ] 737 then 738 ok "a malformed cookie is refused rather than mis-parsed" 739 else 740 fail "a malformed cookie answered $STATUS" 741 fi 742 743 # One payment can be redeemed again, from anywhere: this is deliberate 744 # (design document 076, "Payment buys access, not a seat") and the check 745 # is here so that a change of mind about it shows up as a test failure 746 # rather than as a silent change of policy. 747 STATUS=$(redeem "$WEBSITE" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/again.hdr") 748 if [ "$STATUS" = "303" ] 749 then 750 ok "the redemption is repeatable, as the design document says it is" 751 else 752 fail "redeeming a second time gave $STATUS; if that is intended, 076 needs updating" 753 fi 754 755 echo "=== $CHECKS checks, $FAILS failure(s) ===" 756 if [ "$FAILS" -gt 0 ] 757 then 758 exit 1 759 fi 760 echo "=== all paywall tests passed ===" 761 exit 0