paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

commit 4d6092453dbce9b943019a3accf280b09fa2593d
parent d2d7c542df491ff82f594b39f6ffd47613389d3a
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 23:18:03 +0200

replace badly named cur_time with expiration

Diffstat:
MREADME | 4++++
Msrc/backend/paivana-httpd_cookie.c | 38++++++++++++++++++++++++--------------
Msrc/backend/paivana-httpd_cookie.h | 13+++++++------
Msrc/backend/paivana-httpd_pay.c | 43+++++++++++++++++++++----------------------
Msrc/frontend/paywall.js | 2+-
Msrc/tests/test_cookie_header.c | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
6 files changed, 173 insertions(+), 50 deletions(-)

diff --git a/README b/README @@ -36,6 +36,10 @@ How it works with a reference to the unique payment identifier. 6. `paivana-httpd` verifies the payment with the merchant, sets an HMAC access cookie, and redirects the browser to the original URL. + The access ends at the `expiration` the browser named in step 3 and + repeated in step 5 — it is hashed into the payment identifier, so the + two must agree — bounded above by the contract's `max_pickup_time`, + which is what the merchant's `max_pickup_delay` on the template sets. 7. Requests with a valid cookie are forwarded to the upstream server via libcurl and the response is streamed back. diff --git a/src/backend/paivana-httpd_cookie.c b/src/backend/paivana-httpd_cookie.c @@ -39,17 +39,17 @@ struct GNUNET_HashCode paivana_secret; /** - * Compute access cookie hash for the given @a cur_time, the + * Compute access cookie hash for the given @a expiration, the * @a website and @a ca. * - * @param cur_time current time used in the cookie + * @param expiration time at which the access being granted ends * @param website URL the cookie is valid for * @param ca_len number of bytes in @a ca * @param ca client (IP) address * @param[out] c set to the cookie hash */ static void -compute_cookie_hash (struct GNUNET_TIME_Timestamp cur_time, +compute_cookie_hash (struct GNUNET_TIME_Timestamp expiration, const char *website, size_t ca_len, const void *ca, @@ -58,12 +58,12 @@ compute_cookie_hash (struct GNUNET_TIME_Timestamp cur_time, struct GNUNET_TIME_AbsoluteNBO e; GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, - "Computing cookie for %s at %llu and client %s\n", + "Computing cookie for %s expiring at %llu and client %s\n", website, - (unsigned long long) cur_time.abs_time.abs_value_us, + (unsigned long long) expiration.abs_time.abs_value_us, TALER_b2s (ca, ca_len)); - e = GNUNET_TIME_absolute_hton (cur_time.abs_time); + e = GNUNET_TIME_absolute_hton (expiration.abs_time); if (PH_global_cookie) website = ""; GNUNET_assert (GNUNET_YES == @@ -259,7 +259,7 @@ encode_path (const char *path) char * -PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time, +PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp expiration, const char *website, size_t ca_len, const void *ca) @@ -292,7 +292,17 @@ PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time, "https://", strlen ("https://"))); struct GNUNET_TIME_Relative duration - = GNUNET_TIME_absolute_get_remaining (cur_time.abs_time); + = GNUNET_TIME_absolute_get_remaining (expiration.abs_time); + /* RFC 6265 section 5.2.2: a non-positive Max-Age tells the user agent + to expire the cookie immediately. Truncating to seconds turns any + lifetime below one second into exactly that, so the client would + pay and have the cookie deleted on arrival; give it the one second + the wire format is able to express instead. */ + unsigned long long max_age + = GNUNET_MAX (1LLU, + (unsigned long long) (duration.rel_value_us + / GNUNET_TIME_UNIT_SECONDS. + rel_value_us)); if (! PH_global_cookie) { @@ -315,7 +325,7 @@ PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time, website); } } - compute_cookie_hash (cur_time, + compute_cookie_hash (expiration, website, ca_len, ca, @@ -333,20 +343,20 @@ PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time, -- every ';' is followed by SP and an attribute, so the value must not end on one. */ PAIVANA_COOKIE_NAME "=%llu-%s; %sPath=%s; Max-Age=%llu; HttpOnly", - (unsigned long long) (cur_time.abs_time.abs_value_us / 1000LLU / 1000LLU), + (unsigned long long) (expiration.abs_time.abs_value_us / 1000LLU / 1000LLU), cstr, use_https ? "Secure; " : "", url, - (unsigned long long) (duration.rel_value_us / 1000 / 1000)); + max_age); GNUNET_free (epath); return res; } char * -PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp cur_time, +PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp expiration, const char *website, const struct PAIVANA_Nonce *nonce) { @@ -357,7 +367,7 @@ PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp cur_time, char *cstr; size_t clen; - e = GNUNET_TIME_absolute_hton (cur_time.abs_time); + e = GNUNET_TIME_absolute_hton (expiration.abs_time); GNUNET_assert (0 == gcry_md_open (&hd, GCRY_MD_SHA256, @@ -380,7 +390,7 @@ PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp cur_time, GNUNET_asprintf ( &res, "%llu-%.*s", - (unsigned long long) (cur_time.abs_time.abs_value_us / 1000LLU / 1000LLU), + (unsigned long long) (expiration.abs_time.abs_value_us / 1000LLU / 1000LLU), (int) clen, cstr); GNUNET_free (cstr); diff --git a/src/backend/paivana-httpd_cookie.h b/src/backend/paivana-httpd_cookie.h @@ -71,7 +71,7 @@ PAIVANA_HTTPD_check_cookie (const char *cookie, /** * Compute the `Set-Cookie` line granting access to @a website until - * @a cur_time for a client at @a ca. + * @a expiration for a client at @a ca. * * The `Path` attribute is scoped to @a website (unless * #PH_global_cookie), percent-encoded so that it path-matches the @@ -79,30 +79,31 @@ PAIVANA_HTTPD_check_cookie (const char *cookie, * falls back to "/" for a path that RFC 6265 section 4.1.1 cannot * express. `Secure` follows #PH_base_url where that is configured. * - * @param cur_time expiration time of the cookie + * @param expiration expiration time of the cookie * @param website URL of the site the cookie is for * @param ca_len number of bytes in @a ca * @param ca client address * @return the value for the `Set-Cookie` header; the caller must free */ char * -PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time, +PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp expiration, const char *website, size_t ca_len, const void *ca); /** - * Compute the Paivana ID for the given @a cur_time, + * Compute the Paivana ID for the given @a expiration, * @a website and @a nonce. * - * @param cur_time time chosen (by client) + * @param expiration end of access, as chosen by the client and bounded + * by the contract's `max_pickup_time` * @param website website to be accessed * @param nonce client-selected nonce * @return corresponding Paivana ID. */ char * -PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp cur_time, +PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp expiration, const char *website, const struct PAIVANA_Nonce *nonce); diff --git a/src/backend/paivana-httpd_pay.c b/src/backend/paivana-httpd_pay.c @@ -37,15 +37,6 @@ struct PayRequest; #include "taler/merchant/get-private-orders-ORDER_ID.h" /** - * How much do we allow the client clock to be off and still - * accept the client's timestamp? This basically allows a - * client to extend the lifetime of their purchase by putting - * the payment date into the future. - */ -#define CLIENT_PAY_TIME_TOLERANCE \ - GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_SECONDS, 90) - -/** * Handle for processing actual payment. */ struct PayRequest @@ -105,9 +96,12 @@ struct PayRequest struct PAIVANA_Nonce nonce; /** - * Expiration time of the cookie. + * End of the access the client is redeeming: the expiration of the + * cookie we mint, and one of the three inputs the client hashed into + * the paivana_id the order was created under. Chosen by the client + * and bounded above by the contract's `max_pickup_time'. */ - struct GNUNET_TIME_Timestamp cur_time; + struct GNUNET_TIME_Timestamp expiration; /** * HTTP status to return in combination with @e resp to the client. @@ -284,7 +278,7 @@ check_contract (struct PayRequest *ph, ph->response_status = MHD_HTTP_CONFLICT; return false; } - if (GNUNET_TIME_timestamp_cmp (ph->cur_time, + if (GNUNET_TIME_timestamp_cmp (ph->expiration, >, max_time)) { @@ -361,7 +355,7 @@ order_status_cb (struct PayRequest *ph, ph->response_status = MHD_HTTP_INTERNAL_SERVER_ERROR; break; } - cookie = PAIVANA_HTTPD_compute_cookie (ph->cur_time, + cookie = PAIVANA_HTTPD_compute_cookie (ph->expiration, ph->website, ca_len, ca); @@ -452,8 +446,8 @@ PAIVANA_HTTPD_payment_handle (struct PayRequest *ph, &ph->order_id), TALER_JSON_spec_web_url ("website", &ph->website), - GNUNET_JSON_spec_timestamp ("cur_time", - &ph->cur_time), + GNUNET_JSON_spec_timestamp ("expiration", + &ph->expiration), GNUNET_JSON_spec_fixed_auto ("nonce", &ph->nonce), GNUNET_JSON_spec_end () @@ -465,17 +459,22 @@ PAIVANA_HTTPD_payment_handle (struct PayRequest *ph, spec); if (GNUNET_YES != ret) return (GNUNET_NO == ret) ? MHD_YES : MHD_NO; - if (GNUNET_TIME_relative_cmp ( - GNUNET_TIME_absolute_get_remaining (ph->cur_time.abs_time), - >, - CLIENT_PAY_TIME_TOLERANCE)) + /* `expiration' is the end of the access being bought, not a + statement about the client's clock: it is what the cookie's + Max-Age is computed from, what check_cookie() enforces, and what + is hashed into the paivana_id the order was created under, so we + cannot re-derive it here even if we wanted to. An expiration in + the past would mint a cookie that is dead on arrival; the upper + bound is the contract's `max_pickup_time', enforced in + check_contract() once we have the contract to compare against. */ + if (GNUNET_TIME_absolute_is_past (ph->expiration.abs_time)) { - GNUNET_break (0); + GNUNET_break_op (0); return TALER_MHD_reply_with_error ( ph->connection, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MALFORMED, - "cur_time"); + "expiration"); } } GNUNET_assert (NULL == ph->co); @@ -493,7 +492,7 @@ PAIVANA_HTTPD_payment_handle (struct PayRequest *ph, { char *paivana_id; - paivana_id = PAIVANA_HTTPD_compute_paivana_id (ph->cur_time, + paivana_id = PAIVANA_HTTPD_compute_paivana_id (ph->expiration, ph->website, &ph->nonce); GNUNET_assert ( diff --git a/src/frontend/paywall.js b/src/frontend/paywall.js @@ -128,7 +128,7 @@ async function confirmPayment(order_id, linkEl, errorEl, nonce) { body: JSON.stringify({ order_id, nonce, - cur_time: { t_s: expTime }, + expiration: { t_s: expTime }, website, }), }); diff --git a/src/tests/test_cookie_header.c b/src/tests/test_cookie_header.c @@ -21,7 +21,7 @@ /** * @file test_cookie_header.c * @brief tests the `Set-Cookie` line paivana emits for the access - * cookie: its `Path` attribute and its `Secure` attribute + * cookie: its `Path`, `Secure` and `Max-Age` attributes * * The cookie is the credential the client just paid for, and both * attributes decide whether it ever comes back: @@ -35,6 +35,8 @@ * section 4.1.1) admits neither ';' nor CTLs, both of which a URI * path may legitimately carry. * - `Secure` decides whether the credential may travel in the clear. + * - `Max-Age` decides how long it lives, and a value of zero (RFC 6265 + * section 5.2.2) deletes it on arrival. * * The integration suite cannot cover any of this: it runs paivana with * -n, where the cookie path is never reached at all. @@ -58,6 +60,27 @@ static unsigned int failures; /** + * Compute a `Set-Cookie` line granting access to @a website until + * @a expiration, from a fixed client address. + * + * @param website URL the cookie is minted for + * @param expiration end of the access being granted + * @return the header value, to be freed by the caller + */ +static char * +set_cookie_until (const char *website, + struct GNUNET_TIME_Timestamp expiration) +{ + static const uint8_t ca[4] = { 203, 0, 113, 7 }; + + return PAIVANA_HTTPD_compute_cookie (expiration, + website, + sizeof (ca), + ca); +} + + +/** * Compute a `Set-Cookie` line for @a website with an expiration an * hour out and a fixed client address. * @@ -67,13 +90,9 @@ static unsigned int failures; static char * set_cookie (const char *website) { - static const uint8_t ca[4] = { 203, 0, 113, 7 }; - - return PAIVANA_HTTPD_compute_cookie ( - GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + return set_cookie_until ( website, - sizeof (ca), - ca); + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS)); } @@ -112,6 +131,71 @@ attribute (const char *sc, /** + * Check that a cookie expiring at @a expiration carries a `Max-Age` + * between @a lo and @a hi inclusive. + * + * The bounds are a range because the lifetime is measured from the + * moment of the call while @a expiration is second-granular, so an + * hour out is 3599 or 3600 seconds depending on where in the current + * second we happen to be. + * + * @param label what the case is called in the log + * @param expiration end of the access being granted + * @param lo smallest acceptable `Max-Age` + * @param hi largest acceptable `Max-Age` + */ +static void +max_age_between (const char *label, + struct GNUNET_TIME_Timestamp expiration, + unsigned long long lo, + unsigned long long hi) +{ + const char *website = "http://example.com/premium/article"; + char *sc; + char *got; + unsigned long long ma; + + sc = set_cookie_until (website, + expiration); + got = attribute (sc, + "Max-Age"); + if ( (NULL == got) || + (1 != sscanf (got, + "%llu", + &ma)) ) + { + fprintf (stderr, + "FAIL: %s gives Max-Age=%s, want a number in [%llu,%llu]\n", + label, + (NULL != got) ? got : "(none)", + lo, + hi); + failures++; + } + else if ( (ma < lo) || + (ma > hi) ) + { + fprintf (stderr, + "FAIL: %s gives Max-Age=%llu, want [%llu,%llu]\n", + label, + ma, + lo, + hi); + failures++; + } + else + { + fprintf (stderr, + " ok: %s -> Max-Age=%llu\n", + label, + ma); + } + GNUNET_free (got); + GNUNET_free (sc); +} + + +/** * Is the attribute @a name (one without a value, such as `Secure`) * present in the `Set-Cookie` line @a sc? * @@ -463,6 +547,31 @@ main (int argc, PH_base_url = NULL; fprintf (stderr, + "-- Max-Age --\n"); + /* An expiration is second-granular, so one observed from the middle + of the preceding second leaves under a second of lifetime. RFC + 6265 section 5.2.2 reads a Max-Age of zero as "expire this cookie + immediately", which would delete the access the client just paid + for on arrival; the smallest lifetime the attribute can express is + one second. */ + max_age_between ("the next whole second", + GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_relative_to_absolute ( + GNUNET_TIME_UNIT_SECONDS)), + 1, + 1); + max_age_between ("an hour out", + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + 3599, + 3600); + /* 'forever' is the default max_pickup_delay, so it is the ordinary + case rather than an edge one. */ + max_age_between ("forever", + GNUNET_TIME_UNIT_FOREVER_TS, + 365ULL * 24 * 60 * 60, + ULLONG_MAX); + + fprintf (stderr, "-- the cookie value still verifies --\n"); round_trips ("http://example.com/premium/my article"); round_trips ("http://example.com/a;b");