taler-docs

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

commit 544534653ffbe8fb89dae56e85851def89678ae1
parent 645e8db76727cc918d063a7c82cbd0a4a9f2f9b9
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 23:58:31 +0200

fix paivana docs to document better how it works

Diffstat:
Mdesign-documents/076-paywall-proxy.rst | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mfrags/paivana-httpd-manual.rst | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 143 insertions(+), 17 deletions(-)

diff --git a/design-documents/076-paywall-proxy.rst b/design-documents/076-paywall-proxy.rst @@ -48,12 +48,17 @@ Steps: this client IP address and ``{website}`` at this time. The *Paivana Cookie* is computed as: - ``cur_time || '-' || crock32(HKDF(salt=cur_time, ikm=paivana_server_secret, info=website || '\0' || client_ip))``. + ``expiration || '-' || crock32(HKDF(salt=expiration, ikm=paivana_server_secret, info=website || '\0' || client_ip))``. - where ``cur_time`` in the prefix is the expiration time for the + where ``expiration`` in the prefix is the expiration time for the cookie (and thus the access to the article) in seconds (to keep it short) while in the salt it is the binary GNUnet absolute time (microseconds) in network byte order. + Note that this value is the *end of the access being sold*, chosen by + the client and capped by the contract; it is not a statement about + when anything happened, and in particular it is not the client's idea + of the current time. (It was called ``cur_time`` in earlier drafts of + this document, which invited exactly that misreading.) ``HKDF`` is GNUnet's HKDF (``GNUNET_CRYPTO_hkdf_gnunet()``, which extracts with HMAC-SHA-512 and expands with HMAC-SHA-256), and the output is 512 bits. @@ -87,27 +92,33 @@ Steps: ``{paivana_id}`` and fullfillment URL (see below). * The browser (rendering the paywall page) generates a random - *paivana ID* via JS using the current time (``cur_time``) in seconds - since the Epoch and the current URL (``{website}``) plus some - freshly generated entropy (``{nonce}``): + *paivana ID* via JS using the end of the access it intends to buy + (``expiration``) in seconds since the Epoch and the current URL + (``{website}``) plus some freshly generated entropy (``{nonce}``): - ``paivana_id := cur_time || '-' || b64url(SHA256(nonce || website || '\0' || cur_time))``. + ``paivana_id := expiration || '-' || b64url(SHA256(nonce || website || '\0' || expiration))``. The exact byte string that is hashed is the concatenation of: * the 16-byte (128-bit) binary ``nonce``; * the UTF-8 encoding of ``website``, including its terminating zero byte (which separates it unambiguously from the timestamp); - * ``cur_time`` as an 8-byte **big-endian (network byte order) + * ``expiration`` as an 8-byte **big-endian (network byte order) number of microseconds** since the Epoch, that is, the value of - the seconds-based ``cur_time`` multiplied by 1000000. + the seconds-based ``expiration`` multiplied by 1000000. - Note that ``cur_time`` thus appears twice in two different + Note that ``expiration`` thus appears twice in two different encodings: the ``paivana_id`` *prefix* is the timestamp in **seconds** (as decimal ASCII, to keep the identifier short), while the hashed value is the same instant in **microseconds** in network byte order. + The client is free to pick this value — it is asking for access until + a particular moment, and it is the contract's ``max_pickup_time`` that + decides whether it may have it. Since the same value goes into the + session ID the order is created under, it cannot be revised after the + fact. + Here ``b64url`` is the RFC 7515 base64 URL encoder without padding, used to keep the result short (same reason for the use of SHA-256). @@ -136,16 +147,68 @@ Steps: browser (still rendering the paywall page) also learns the order ID. * The JavaScript of the paywall page (or the non-JS client processing the ``Paivana`` HTTP header) then POSTs the order ID, - ``nonce``, ``cur_time`` + ``nonce``, ``expiration`` and ``website`` to ``{domain}/.well-known/pavivana``. In this JSON request, the ``nonce`` is ``crock32``-encoded and - ``cur_time`` is a normal GNU Taler timestamp object (``{"t_s": ...}``, - in seconds); the server re-derives the binary inputs given above - from these values. -* paivana-httpd computes the paivana ID and checks if the given - order ID was indeed paid recently for the computed paivana ID. - If so, it generates an HTTP response which the Paivana cookie - and redirects to the fulfillment URL (which is the original {website}). + ``expiration`` is a normal GNU Taler timestamp object + (``{"t_s": ...}``, in seconds); the server re-derives the binary + inputs given above from these values. + + Note that by the time this POST is made, the client already has the + merchant backend's word that the order was paid: that is precisely + what its long poll on ``/sessions/{paivana_id}`` returned, and it is + where the order ID being posted came from. The step below is + therefore a *confirmation* of something the client has been told, and + not an open-ended wait for a payment that may still be in progress. + +* paivana-httpd re-computes the paivana ID from ``nonce``, ``website`` + and ``expiration``, and asks the merchant backend, over its own + authenticated connection, whether the posted order ID was paid under + exactly that session ID. Recomputing rather than accepting the ID is + what binds the answer to this request: a client cannot post an order + it paid for one article and be let into another, because a different + ``website`` yields a different paivana ID and the order is then not + found under it. + + The reply is accepted only if all of the following hold: + + * the order status is *paid*, and the order has neither been refunded + nor has a refund pending — otherwise a client could take its money + back and keep the cookie; + * the contract's ``fulfillment_url``, if it has one, equals the + posted ``website``; if it has none, the ``website`` must lie under + paivana-httpd's own configured base URL, so that the client cannot + choose which site it is admitted to; + * ``expiration`` is not later than the contract's ``max_pickup_time``, + which is what stops a client from buying five minutes of access and + minting itself a cookie valid for a year. + + If so, paivana-httpd issues the Paivana cookie described above, with + ``Max-Age`` derived from ``expiration``, and redirects to the + ``{website}``. + + This query is made as a **long poll with a short, fixed bound** (5 + seconds in the current implementation). Both halves matter: + + * *Long poll*, because the client's confirmation and the backend's own + view of the order can be a moment apart, and because paivana-httpd + and the client may be talking to different backend processes. An + honest client that is merely early is waited for rather than turned + away, which is the difference between a working paywall and one that + intermittently refuses people who have paid. + * *Short and bounded*, because this endpoint is unauthenticated and + reachable before any payment has been shown to exist. The wait is + the interval for which an attacker can pin a connection by posting a + random order ID, so it is a cost that is deliberately kept small. + The same bound is applied client-side, so a merchant backend that + stops answering cannot pin connections either. + + Where the order genuinely was not paid, the client is told so (HTTP + 409 Conflict) after that bound has elapsed; where the backend did not + answer at all, it gets 504 Gateway Timeout, and where the backend + answered something unusable, 502 Bad Gateway. Distinguishing these + matters operationally: only the first is the client's fault. + * The browser reloads the page with the correct Paivana cookie (see first step). diff --git a/frags/paivana-httpd-manual.rst b/frags/paivana-httpd-manual.rst @@ -447,6 +447,69 @@ unset means a new random key is chosen at every startup and all outstanding cookies are invalidated on restart. +.. _Paivana-Redemption: + +Redeeming a payment +------------------- + +The cookie above is handed out at the end of one round-trip that is +worth understanding, because it is the only point at which +``paivana-httpd`` talks to the merchant backend on behalf of a client, +and because its failure modes are what you will see in the logs. + +When the paywall page has been told by the merchant backend that its +order was paid, it POSTs the order ID (together with the ``nonce``, +``expiration`` and ``website`` it used to construct the payment) to +``/.well-known/paivana``. ``paivana-httpd`` re-derives the session ID +from those values and asks its backend whether that order was really +paid under it, over its own authenticated connection. Nothing the +client says is taken on trust: the order must be paid and not refunded, +its fulfillment URL must be the ``website`` being claimed (or, where the +order has none, that ``website`` must lie under the configured +``BASE_URL``), and the requested expiration must not exceed the order's +``max_pickup_time``. Only then is the cookie issued. + +This query is a **long poll bounded at five seconds**, and both parts +are deliberate. A client can be a moment ahead of the backend — it may +even be talking to a different backend process than ``paivana-httpd`` +is — so waiting briefly admits an honest client that would otherwise be +turned away just after paying. Conversely, the endpoint is +unauthenticated and can be posted to before any payment exists, so that +wait is also the time an attacker can pin a connection for, which is +why it is short rather than generous. The same bound applies to the +outgoing request itself, so a merchant backend that stops responding +cannot hold connections open either. + +The status returned to the client tells you which of these happened: + +======== ============================================================== +Status Meaning +======== ============================================================== +303 Payment confirmed; the ``Paivana-Cookie`` is set and the + client is redirected to the content. +409 The backend says the order was not paid, or it was refunded, + or its contract does not cover the claimed website. This is + the client's problem, not yours — a stale replay, or a page + that gave up too early. +410 The client asked for access past the order's + ``max_pickup_time``. Raise ``max_pickup_duration`` on the + template if legitimate clients hit this. +404 The backend does not know this order at all. +504 The backend did not answer within the bound, or could not be + reached. Check that ``MERCHANT_BACKEND_URL`` is reachable + and that the backend is not itself stalled; the log line + naming the backend and order ID is emitted at ``WARNING``. +502 The backend answered, but with something ``paivana-httpd`` + could not use. This normally means a version mismatch + between the two and is worth reporting. +500 The backend rejected our credentials. Check + ``MERCHANT_ACCESS_TOKEN``; no client can fix this. +======== ============================================================== + +A burst of 504s or 500s is therefore an infrastructure problem and a +burst of 409s is not, which is the distinction to key any alerting on. + + .. _Paivana-Templates: Configuring Paivana templates