taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

076-paywall-proxy.rst (21684B)


      1 DD 76: Paivana - Fighting AI Bots with GNU Taler
      2 ################################################
      3 
      4 :Design status: Accepted
      5 :Implementation status: Implemented
      6 :DD shepherd: TBD
      7 :Historical contributors: Florian Dold, Christian Grothoff
      8 :First published: 2025-11-26
      9 :Last substantive change: 2026-08-07
     10 :Implementation evidence: ``merchant`` (2026-01-20; 2026-04-25; 2026-04-27; 2026-08-04); ``paivana`` (2026-04-19)
     11 :Normative references: ``taler-paivana-manual.rst``, ``frags/paivana-httpd-manual.rst``, and ``core/api-merchant.rst``
     12 
     13 Summary
     14 =======
     15 
     16 This design document describes the architecture of an AI Web firewall using GNU
     17 Taler, as well as new features that are required for the implementation.
     18 
     19 Motivation
     20 ==========
     21 
     22 AI bots are causing enormous amounts of traffic by scraping sites like git
     23 forges. They neither respect robots.txt nor 5xx HTTP responses. Solutions like
     24 Anubis and IP-based blocking do not work anymore at this point.
     25 
     26 Requirements
     27 ============
     28 
     29 * Must withstand high traffic from bots, requests before a payment happened
     30   must be *very* cheap, both in terms of response generation and database
     31   interaction. This includes good support for caching.
     32 * Should work not just for our paivana-httpd but also for Turnstile-style
     33   paywalls that need to work with purely static paywall pages without
     34   PHP sessions.
     35 
     36 
     37 Proposed Solution
     38 =================
     39 
     40 Architecture
     41 ------------
     42 
     43 * paivana-httpd is a reverse proxy that sits between ingress HTTP(S) traffic
     44   and the protected upstream service.
     45 * paivana-httpd is configured with a particular merchant backend.
     46 * A payment template must be set up in the merchant backend (called ``{template_id}``
     47   from here on).
     48 
     49 Steps:
     50 
     51 * Browser visits ``{website}``
     52   (for example, ``https://git.taler.net``) where
     53   ``{domain}`` is the domain name of ``{website}``.
     54 * paivana-httpd working as a reverse-proxy for
     55   ``{website}``. Whenever called for a non-whitelisted
     56   URL, it checks for a the presence of a Paivana cookie valid for
     57   this client IP address and ``{website}`` at this time.
     58   The *Paivana Cookie* is computed as:
     59 
     60   ``expiration || '-' || crock32(HKDF(salt=expiration, ikm=paivana_server_secret, info=website || '\0' || client_ip))``.
     61 
     62   where ``expiration`` in the prefix is the expiration time for the
     63   cookie (and thus the access to the article) in seconds
     64   (to keep it short) while in the salt it is the binary GNUnet
     65   absolute time (microseconds) in network byte order.
     66   Note that this value is the *end of the access being sold*, chosen by
     67   the client and capped by the contract; it is not a statement about
     68   when anything happened, and in particular it is not the client's idea
     69   of the current time.  (It was called ``cur_time`` in earlier drafts of
     70   this document, which invited exactly that misreading.)
     71   ``HKDF`` is GNUnet's HKDF (``GNUNET_CRYPTO_hkdf_gnunet()``, which
     72   extracts with HMAC-SHA-512 and expands with HMAC-SHA-256), and the
     73   output is 512 bits.
     74   Using a keyed PRF instead of a plain hash over the concatenation
     75   ensures that the cookie cannot be forged without the server secret
     76   and that the inputs are unambiguously separated: ``website`` is
     77   terminated by a zero byte before ``client_ip`` is appended, so
     78   different ``(website, client_ip)`` pairs can never yield the same
     79   ``info`` string.
     80   ``crock32`` is GNUnet's Crockford-inspired base32 encoding.
     81 
     82   The cookie is computed and verified exclusively by paivana-httpd;
     83   the browser only stores and returns it and thus never has to
     84   reconstruct this value.
     85 
     86   * If such a cookie is set and valid, the request is
     87     reverse-proxied to upstream. *Stop.*
     88   * Otherwise, an HTTP 303 See Other to
     89     ``/.well-known/paivana/templates/$ID#$WEBSITE``
     90     is returned. Here, ``$ID`` is the template ID and
     91     ``$WEBSITE`` is base64url-encoding of the full URL of
     92     the website currently being visited. This way,
     93     the template page can be fully static and cached, and the
     94     JavaScript logic on that page can learn which website
     95     to pay for (and after payment redirect the browser there).
     96 
     97 * When the browser requests ``/.well-known/paivana/templates/$ID``
     98    a static **cachable** paywall page is returned,
     99    including a machine-readable ``Paivana`` HTTP header with
    100    the ``taler://pay-template/`` URL minus the client-computed
    101    ``{paivana_id}`` and fullfillment URL (see below).
    102 
    103 * The browser (rendering the paywall page) generates a random
    104   *paivana ID* via JS using the end of the access it intends to buy
    105   (``expiration``) in seconds since the Epoch and the current URL
    106   (``{website}``) plus some freshly generated entropy (``{nonce}``):
    107 
    108   ``paivana_id := expiration || '-' || b64url(SHA256(nonce || website || '\0' || expiration))``.
    109 
    110   The exact byte string that is hashed is the concatenation of:
    111 
    112   * the 16-byte (128-bit) binary ``nonce``;
    113   * the UTF-8 encoding of ``website``, including its terminating
    114     zero byte (which separates it unambiguously from the timestamp);
    115   * ``expiration`` as an 8-byte **big-endian (network byte order)
    116     number of microseconds** since the Epoch, that is, the value of
    117     the seconds-based ``expiration`` multiplied by 1000000.
    118 
    119   Note that ``expiration`` thus appears twice in two different
    120   encodings: the ``paivana_id`` *prefix* is the timestamp in
    121   **seconds** (as decimal ASCII, to keep the identifier short),
    122   while the hashed value is the same instant in **microseconds**
    123   in network byte order.
    124 
    125   The client is free to pick this value — it is asking for access until
    126   a particular moment, and it is the contract's ``max_pickup_time`` that
    127   decides whether it may have it.  Since the same value goes into the
    128   session ID the order is created under, it cannot be revised after the
    129   fact.
    130 
    131   Here ``b64url`` is the RFC 7515 base64 URL encoder without
    132   padding, used to keep the result short (same reason for the use of
    133   SHA-256).
    134   The same computation could also easily be done by a non-JS client
    135   that processes the ``Paivana`` HTTP header (or a GNU Taler wallet
    136   running as a Web extension).
    137 
    138 * Based on this paivana ID, a
    139   ``taler://pay-template/{merchant_backend}/{template_id}?session_id={paivana_id}&fulfillment_url={website}``
    140   URI is generated and rendered as a QR code and link, prompting
    141   the user to pay for access to the ``{website}`` using GNU Taler.
    142 
    143 * The JavaScript in the paywall page running in the browser
    144   (or the non-JS client) long-polls
    145   on a new ``https://{merchant_backend}/sessions/{paivana_id}``
    146   endpoint that returns when an order with the given session ID has been paid
    147   for (regardless of the order ID, which is not known to the browser).
    148 * A wallet now needs to instantiate the pay template, passing the
    149   ``session_id`` and the ``fulfillment_url`` as an additional inputs
    150   to the order creation (the session ID here will work just like
    151   existing use of ``session_ids`` in session-bound payments).
    152   Similarly, the ``{website}`` works as the fulfillment URL as usual.
    153 * The wallet then must pay for the resulting order
    154   by talking to the Merchant backend.
    155 * When the long-poller returns and the payment has succeeded, the
    156   browser (still rendering the paywall page) also learns the order ID.
    157 * The JavaScript of the paywall page (or the non-JS client
    158   processing the ``Paivana`` HTTP header) then POSTs the order ID,
    159   ``nonce``, ``expiration``
    160   and ``website`` to ``{domain}/.well-known/paivana``.
    161   In this JSON request, the ``nonce`` is ``crock32``-encoded and
    162   ``expiration`` is a normal GNU Taler timestamp object
    163   (``{"t_s": ...}``, in seconds); the server re-derives the binary
    164   inputs given above from these values.
    165 
    166   Note that by the time this POST is made, the client already has the
    167   merchant backend's word that the order was paid: that is precisely
    168   what its long poll on ``/sessions/{paivana_id}`` returned, and it is
    169   where the order ID being posted came from.  The step below is
    170   therefore a *confirmation* of something the client has been told, and
    171   not an open-ended wait for a payment that may still be in progress.
    172 
    173 * paivana-httpd re-computes the paivana ID from ``nonce``, ``website``
    174   and ``expiration``, and asks the merchant backend, over its own
    175   authenticated connection, whether the posted order ID was paid under
    176   exactly that session ID.  Recomputing rather than accepting the ID is
    177   what binds the answer to this request: a client cannot post an order
    178   it paid for one article and be let into another, because a different
    179   ``website`` yields a different paivana ID and the order is then not
    180   found under it.
    181 
    182   The reply is accepted only if all of the following hold:
    183 
    184   * the order status is *paid*, and the order has neither been refunded
    185     nor has a refund pending — otherwise a client could take its money
    186     back and keep the cookie;
    187   * the contract's ``fulfillment_url``, if it has one, equals the
    188     posted ``website``; if it has none, the ``website`` must lie under
    189     paivana-httpd's own configured base URL, so that the client cannot
    190     choose which site it is admitted to;
    191   * ``expiration`` is not later than the contract's ``max_pickup_time``,
    192     which is what stops a client from buying five minutes of access and
    193     minting itself a cookie valid for a year.
    194 
    195   For repeat visits, the wallet may replay an earlier paid order only if
    196   its access window is still open and its ``max_pickup_time`` covers the
    197   session's requested ``expiration``. An absent or ``never`` deadline is
    198   unlimited. A repeat visit does not extend the paid access period.
    199 
    200   When the wallet itself constructs the Paivana session, it can reuse
    201   remaining access by capping ``expiration`` at the earlier order's
    202   ``max_pickup_time`` **before** computing the session ID. The same capped
    203   expiration must be used when redeeming the order for a cookie. A session
    204   constructed by a browser already binds the expiration and nonce; the
    205   wallet cannot change that expiration. If the previous order cannot cover
    206   it, the wallet offers a new purchase requiring confirmation. After the
    207   old access window expires, a new purchase is required in either flow.
    208 
    209   If so, paivana-httpd issues the Paivana cookie described above, with
    210   ``Max-Age`` derived from ``expiration``, and redirects to the
    211   ``{website}``.
    212 
    213   This query is made as a **long poll with a short, fixed bound** (5
    214   seconds in the current implementation).  Both halves matter:
    215 
    216   * *Long poll*, because the client's confirmation and the backend's own
    217     view of the order can be a moment apart, and because paivana-httpd
    218     and the client may be talking to different backend processes.  An
    219     honest client that is merely early is waited for rather than turned
    220     away, which is the difference between a working paywall and one that
    221     intermittently refuses people who have paid.
    222   * *Short and bounded*, because this endpoint is unauthenticated and
    223     reachable before any payment has been shown to exist.  The wait is
    224     the interval for which an attacker can pin a connection by posting a
    225     random order ID, so it is a cost that is deliberately kept small.
    226     The same bound is applied client-side, so a merchant backend that
    227     stops answering cannot pin connections either.
    228 
    229   Where the order genuinely was not paid, the client is told so (HTTP
    230   409 Conflict) after that bound has elapsed; where the backend did not
    231   answer at all, it gets 504 Gateway Timeout, and where the backend
    232   answered something unusable, 502 Bad Gateway.  Distinguishing these
    233   matters operationally: only the first is the client's fault.
    234 
    235 * The browser reloads the page with the correct
    236   Paivana cookie (see first step).
    237 
    238 
    239 Problems:
    240 ---------
    241 
    242 * A smart attacker might still create a lot of orders via the pay-template.
    243 
    244   * Solution A: Don't care, unlikely to happen in the first place.
    245   * Solution B: Rate-limit template instantiation on a per-IP basis.
    246 
    247 Accepted risks:
    248 ---------------
    249 
    250 Four properties of this design were examined and deliberately kept as they
    251 are.  Each is recorded here together with the assumption that makes it
    252 acceptable, because a deployment that does not satisfy the assumption does
    253 not get the property.
    254 
    255 Only one price per URL
    256 ~~~~~~~~~~~~~~~~~~~~~~
    257 
    258 paivana-httpd quotes a price by taking the first configured template whose
    259 anchored ``website_regex`` matches the requested URL; a template configured
    260 without a regex matches every URL.  At redemption, the checks listed above
    261 are all that can be made: the contract carries a fulfillment URL and a
    262 ``max_pickup_time``, and it does not carry the identity of the template it
    263 was instantiated from.  paivana-httpd therefore cannot tell an order created
    264 under one template from an order created under another, and in particular
    265 cannot check that the amount paid is the amount its own template search
    266 would have quoted for the URL being unlocked.
    267 
    268 Where an instance carries more than one paivana template — or one template
    269 without a ``website_regex``, which matches everything — this is exploitable
    270 in the obvious way.  A client that wants an expensive URL instantiates the
    271 cheap template with that URL as its fulfillment URL, pays the cheap price,
    272 and posts the result for redemption; both orders name the same fulfillment
    273 URL, which is all the redemption check inspects.  The exposure is wider than
    274 the configured regular expressions suggest, because the merchant backend
    275 matches ``website_regex`` unanchored where paivana-httpd anchors it: the set
    276 of URLs the backend will sell a template for is a superset of the set
    277 paivana-httpd paywalls with it.
    278 
    279 The mitigation is a property of the configuration rather than of the code.
    280 A merchant instance used by paivana-httpd carries exactly one paivana
    281 template, so that every URL it paywalls has exactly one price and there is
    282 nothing to substitute; differentiated pricing across a site is then a matter
    283 of separate instances, each with its own template and its own paivana-httpd.
    284 **The risk is accepted on the assumption that a deployment presents a single
    285 price for every URL it paywalls.**  A deployment that puts two paivana
    286 templates on one instance is selling its expensive articles at the cheaper
    287 price.
    288 
    289 Closing the gap properly requires the merchant backend to record the
    290 instantiating ``template_id`` in the contract terms and to report it with
    291 the order status; paivana-httpd could then re-run its own template search
    292 for the posted ``website`` and require the two to agree.  That is a
    293 merchant-side change in a separate upstream, and this document does not
    294 assume it.
    295 
    296 Payment buys access, not a seat
    297 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    298 
    299 The redemption POST is idempotent and unmetered, and nothing records that an
    300 order or a paivana ID has already been redeemed.  The cookie it returns is
    301 bound to the address of whoever posted the redemption, not to the address
    302 that paid.  Anyone holding the four posted fields — order ID, ``nonce``,
    303 ``expiration`` and ``website`` — can therefore obtain their own cookie, for
    304 their own address, at any point until ``expiration``, and a buyer who
    305 publishes those four fields has given the article to everyone who reads
    306 them.
    307 
    308 This is intended behaviour and not a defect.  What is sold is access to one
    309 resource until one moment, and the buyer may pass that on, in the same way
    310 and for the same reasons that the buyer of a newspaper may hand it to the
    311 next reader.  The purchase stays bounded by what was bought: sharing extends
    312 a payment to more readers, never to more URLs and never past ``expiration``,
    313 so a client that wants the whole site still pays for the whole site.  That
    314 bound is what lets the design's actual goal — making bulk automated
    315 retrieval expensive — survive the sharing.  **The risk is accepted on the
    316 assumption that deployments price access per resource and per unit of time,
    317 and that none of them requires per-seat licensing**, which this design
    318 cannot provide and must not be configured as though it could.
    319 
    320 One consequence has to be stated plainly, because the construction of the
    321 cookie invites the opposite reading: binding the cookie to the client
    322 address is a cookie-theft mitigation and nothing else.  It ensures that a
    323 cookie which leaks — from a log, a shared machine, a proxy — is useless to
    324 whoever picks it up.  It provides no anti-sharing property whatsoever, since
    325 the redemption that mints cookies is open to every address.
    326 
    327 The redemption endpoint is thus unmetered by intent.  Metering it would not
    328 restore any property this design claims; the rate-limiting question raised
    329 above for template instantiation is a question about load, it applies to
    330 this endpoint in the same form, and it is open in the same way.
    331 
    332 Entropy of the server secret
    333 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    334 
    335 ``paivana_server_secret`` is derived from the configured secret by a single
    336 unsalted SHA-512.  There is no stretching and no salt, so the cost of
    337 guessing that secret offline from one observed cookie is one hash per
    338 candidate: the strength of every cookie the deployment will ever issue is
    339 the entropy of the configured string, and nothing more.
    340 
    341 The requirement that follows is placed on the operator.  The configured
    342 secret must carry at least 128 bits drawn from a cryptographic random
    343 source, and must never be a passphrase, a hostname, a token reused from
    344 elsewhere, or the placeholder that ships in the sample configuration — which
    345 is an example rather than a secret, and leaves a deployment that keeps it
    346 with no secret at all.  Where no secret is configured, paivana-httpd uses a
    347 fresh random value per process, which is safe but invalidates every
    348 outstanding cookie whenever the service restarts.
    349 
    350 Absorbing the requirement into the construction was considered and rejected.
    351 A memory-hard KDF exists to make human-chosen, low-entropy secrets expensive
    352 to guess; it buys a fixed factor, no fixed factor rescues a guessable
    353 phrase, and against 128 genuine bits it buys nothing that is needed.
    354 ``paivana_server_secret`` is a machine-generated configuration value that
    355 nobody has to remember or type, so the situation a KDF defends against is
    356 one the deployment can simply not be in.  **The risk is accepted on the
    357 assumption that the secret is produced by a random generator and never
    358 chosen by a person**; where a person chooses it, the cookies are forgeable
    359 and the paywall is decorative.  How to generate such a secret is operator
    360 guidance and belongs with the manual rather than here.
    361 
    362 Truncation is unreportable to an HTTP/1.0 client
    363 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    364 
    365 paivana-httpd relays bodies as they arrive rather than assembling them
    366 first, which is what lets it serve content larger than memory and lets the
    367 client start receiving before the upstream has finished.  The cost is that
    368 the upstream's status line and headers reach the client long before the
    369 body is complete, so an upstream that fails mid-body cannot be reported as
    370 ``502`` — that status has already been spent.
    371 
    372 What is left is to break the framing, which for almost every client is
    373 enough: a declared ``Content-Length`` is left unmet, or a chunked response
    374 is closed without its terminating chunk, and RFC 9112 section 8.1.2
    375 requires a recipient to treat either as a failed message.  The exception is
    376 an HTTP/1.0 client receiving a response whose length the upstream never
    377 declared.  Such a client cannot be sent chunks, so the close of the
    378 connection *is* the end-of-body marker, and a truncated body is
    379 byte-for-byte indistinguishable from a complete one.
    380 
    381 The alternative would be to buffer each response until it is known to be
    382 complete, which is exactly the property being given up, and which bounds
    383 every response by memory to buy correct reporting for one obsolete client
    384 version. **The risk is accepted on the assumption that clients speak
    385 HTTP/1.1**, which every browser and every HTTP library in current use has
    386 done since well before this design; where a genuine HTTP/1.0 client is
    387 expected, the upstream should be configured to declare a
    388 ``Content-Length``, which restores detection for it too.
    389 
    390 Implementation
    391 --------------
    392 
    393 * [x] Merchant backend can look up order IDs under a Paivana session ID.
    394 * [x] Merchant backend can instantiate Paivana templates with ``paivana_id``
    395   and the target website.
    396 * [x] Paivana component implemented.
    397 * [x] Wallet/Web utility support implemented.
    398 
    399 
    400 Test Plan
    401 =========
    402 
    403 * Deploy it for git.taler.net
    404 
    405 Definition of Done
    406 ==================
    407 
    408 * [x] Merchant, Paivana, and wallet-side protocol support implemented.
    409 * [x] Protocol and operator documentation published.
    410 * [ ] Production deployment and end-to-end QC recorded.
    411 
    412 Alternatives
    413 ============
    414 
    415 * Do not re-use the session ID mechanism but introduce some new concept.
    416   This has the drawback of us needing additional tables and indicies,
    417   and also the existing use of the session ID is very parallel to this one.
    418 * Instead of doing a 303 See Other, cache control could have been achieved by
    419   specifying a "Vary: Cookie" HTTP header. We may combine these and use
    420   that to additionally enable caching of the 303 See Other. The 303 solution
    421   has the advantage that there is only one page to cache per template, and
    422   the disadvantage of an additional redirect. Note that this is purely
    423   a frontend design choice, wallets and merchant backends work nicely with
    424   either approach.
    425 
    426 Drawbacks
    427 =========
    428 
    429 * This exposes an order ID to anyone who knows the session ID. This is
    430   clearly not an issue in this context, and for the existing uses of
    431   the session ID it also seems clear that knowledge of the session ID
    432   requires an attacker to have access that would easily also already
    433   give them any order ID, so this seems harmless.
    434 
    435 
    436 Discussion / Q&A
    437 ================