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