turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

commit cdd13ca614da11022db8711ab760cad90c863edb
parent 633a036ca2450d664bc621ac7fcc3288772b23ed
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed, 20 May 2026 19:27:52 +0200

align paivana_id with canonical spec:

- Switch JS and PHP to the canonical paivana_id algorithm from
  paivana-httpd_cookie.c: 16-byte binary nonce (Crockford base32 on
  the wire), website + NUL, 8-byte big-endian microseconds.
- Drop the language prefix from the confirm URL by switching to
  Url::fromUserInput, so logs no longer show /en/taler-turnstile/paivana.
- In taler_turnstile_entity_view_alter, strip only the rendered field
  children and keep all entity render metadata (#entity_type, #node,
  ...). Replacing \$build wholesale crashed the title callback with
  "Undefined array key '#entity_type'".

Diffstat:
Mjs/payment-button.js | 62+++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/Controller/PaivanaController.php | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtaler_turnstile.module | 44+++++++++++++++++++++++++++-----------------
3 files changed, 135 insertions(+), 33 deletions(-)

diff --git a/js/payment-button.js b/js/payment-button.js @@ -26,14 +26,51 @@ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } - function sha256b64(str) { - return crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)) - .then(function (buf) { return toBase64Url(new Uint8Array(buf)); }); + // GNUnet's Crockford base32 alphabet (omits I, L, O, U). + var CROCK_TABLE = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + + function encodeCrock(bytes) { + var sb = ''; + var size = bytes.length; + var bitBuf = 0; + var numBits = 0; + var pos = 0; + while (pos < size || numBits > 0) { + if (pos < size && numBits < 5) { + bitBuf = (bitBuf << 8) | bytes[pos++]; + numBits += 8; + } + if (numBits < 5) { + bitBuf = bitBuf << (5 - numBits); + numBits = 5; + } + var v = (bitBuf >>> (numBits - 5)) & 31; + sb += CROCK_TABLE[v]; + numBits -= 5; + } + return sb; + } + + // 8-byte big-endian microseconds, matching GNUNET_TIME_AbsoluteNBO. + function timestampToBE64Microseconds(sec) { + var b = new ArrayBuffer(8); + var v = new DataView(b); + v.setBigUint64(0, BigInt(sec) * 1000000n); + return new Uint8Array(b); } - function makePaivanaId(curTime, nonce, website) { - return sha256b64(nonce + website + String(curTime)).then(function (hash) { - return curTime + '-' + hash; + // Canonical paivana ID: <seconds> "-" b64url(SHA256( + // nonce_bytes || website || "\0" || us_BE64)) + // See paivana-httpd_cookie.c::PAIVANA_HTTPD_compute_paivana_id. + function makePaivanaId(curTime, nonceBuf, website) { + var websiteBuf = new TextEncoder().encode(website + '\0'); + var curTimeBuf = timestampToBE64Microseconds(curTime); + var buf = new Uint8Array(nonceBuf.length + websiteBuf.length + curTimeBuf.length); + buf.set(nonceBuf, 0); + buf.set(websiteBuf, nonceBuf.length); + buf.set(curTimeBuf, nonceBuf.length + websiteBuf.length); + return crypto.subtle.digest('SHA-256', buf).then(function (hashBuf) { + return curTime + '-' + toBase64Url(new Uint8Array(hashBuf)); }); } @@ -64,13 +101,12 @@ // otherwise honor what the server template advertised. var pickup = Math.min(maxPickupDelay, 60 * 60 * 24 * 365 * 100); var curTime = Math.floor(Date.now() / 1000) + pickup; - var nonceArr = new Uint8Array(32); - crypto.getRandomValues(nonceArr); - var nonce = Array.from(nonceArr) - .map(function (b) { return b.toString(16).padStart(2, '0'); }) - .join(''); + // Canonical PAIVANA_Nonce is 16 bytes (4 * uint32_t). + var nonceBuf = new Uint8Array(16); + crypto.getRandomValues(nonceBuf); + var nonce = encodeCrock(nonceBuf); - makePaivanaId(curTime, nonce, fulfillmentUrl).then(function (paivanaId) { + makePaivanaId(curTime, nonceBuf, fulfillmentUrl).then(function (paivanaId) { var talerUri = buildTalerUri(paivanaUri, paivanaId, fulfillmentUrl); // QR + click-through link. @@ -138,7 +174,7 @@ body: JSON.stringify({ order_id: orderId, nonce: ctx.nonce, - cur_time: ctx.curTime, + cur_time: { t_s: ctx.curTime }, website: ctx.fulfillmentUrl }) }).then(function (res) { diff --git a/src/Controller/PaivanaController.php b/src/Controller/PaivanaController.php @@ -84,8 +84,49 @@ class PaivanaController extends ControllerBase { /** + * Decode a Crockford base32-encoded string (GNUnet flavor: + * alphabet "0123456789ABCDEFGHJKMNPQRSTVWXYZ") into raw bytes. + * Returns FALSE on malformed input. + */ + protected static function crock32Decode(string $encoded) { + static $rmap = NULL; + if ($rmap === NULL) { + $rmap = []; + $table = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + for ($i = 0; $i < 32; $i++) { + $rmap[$table[$i]] = $i; + } + } + $encoded = strtoupper($encoded); + $bytes = ''; + $buf = 0; + $bits = 0; + $len = strlen($encoded); + for ($i = 0; $i < $len; $i++) { + $c = $encoded[$i]; + if (!isset($rmap[$c])) { + return FALSE; + } + $buf = ($buf << 5) | $rmap[$c]; + $bits += 5; + if ($bits >= 8) { + $bytes .= chr(($buf >> ($bits - 8)) & 0xFF); + $bits -= 8; + } + } + return $bytes; + } + + /** * POST handler. Body: {order_id, nonce, cur_time, website}. * On success, returns 303 + Set-Cookie + Location: $website. + * + * Wire format follows the canonical Paivana spec (see + * paivana-httpd_pay.c / paivana-httpd_cookie.c): + * - nonce: Crockford base32-encoded 16 binary bytes + * - cur_time: GNUnet timestamp object {"t_s": <seconds>} + * - paivana_id := <seconds> "-" b64url(SHA256( + * nonce_bytes || website_bytes || "\0" || us_BE64)) */ public function confirm(Request $request) { $payload = json_decode((string) $request->getContent(), TRUE); @@ -94,18 +135,33 @@ class PaivanaController extends ControllerBase { } $order_id = $payload['order_id'] ?? NULL; $nonce = $payload['nonce'] ?? NULL; - $cur_time = $payload['cur_time'] ?? NULL; + $cur_time_obj = $payload['cur_time'] ?? NULL; $website = $payload['website'] ?? NULL; if (! is_string($order_id) || ! is_string($nonce) || - ! is_int($cur_time) || ! is_string($website)) { + ! is_array($cur_time_obj) || ! isset($cur_time_obj['t_s']) || + ! is_int($cur_time_obj['t_s']) || ! is_string($website)) { return new JsonResponse(['error' => 'missing_or_malformed_fields'], 400); } + $cur_time = $cur_time_obj['t_s']; if ($cur_time <= time()) { return new JsonResponse(['error' => 'expired'], 410); } + // Canonical PAIVANA_Nonce is 16 bytes (4 * uint32_t), encoded + // on the wire as Crockford base32 by GNUNET_JSON_spec_fixed_auto. + $nonce_bytes = self::crock32Decode($nonce); + if ($nonce_bytes === FALSE || strlen($nonce_bytes) < 16) { + return new JsonResponse(['error' => 'bad_nonce'], 400); + } + $nonce_bytes = substr($nonce_bytes, 0, 16); + // Reconstruct the paivana ID exactly the same way the JS did. - $hash = hash('sha256', $nonce . $website . (string) $cur_time, TRUE); + // The hash input is: 16-byte binary nonce || website || NUL || + // 8-byte big-endian microseconds. + $cur_time_be = pack('J', $cur_time * 1000000); + $hash = hash('sha256', + $nonce_bytes . $website . "\0" . $cur_time_be, + TRUE); $paivana_id = $cur_time . '-' . rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); // Resolve the website to a Drupal node so we can determine the diff --git a/taler_turnstile.module b/taler_turnstile.module @@ -167,7 +167,10 @@ function taler_turnstile_entity_view_alter(array &$build, EntityInterface $entit '#price_hint' => $price_category->getPriceHint(), '#subscription_hint' => $price_category->getSubscriptionHint(), '#fulfillment_url' => $fulfillment_url, - '#confirm_url' => \Drupal\Core\Url::fromRoute('taler_turnstile.paivana_confirm', [], ['absolute' => FALSE])->toString(), + // Built from the raw path (not Url::fromRoute) so the URL never + // carries a language prefix — the endpoint is language-agnostic + // and a /en/ in front just confuses operators reading logs. + '#confirm_url' => \Drupal\Core\Url::fromUserInput('/taler-turnstile/paivana')->toString(), '#paivana_uri' => $taler_uri, '#attached' => [ 'library' => ['taler_turnstile/payment_button'], @@ -183,22 +186,29 @@ function taler_turnstile_entity_view_alter(array &$build, EntityInterface $entit $view_builder = \Drupal::entityTypeManager()->getViewBuilder('node'); $teaser_build = $view_builder->view($entity, 'teaser'); - $build = [ - // Page is identical for everyone *without* the cookie, so it - // remains cacheable. The cookie context only kicks in once the - // visitor has paid. - '#cache' => [ - 'contexts' => array_merge( - $build['#cache']['contexts'] ?? [], - ['cookies:' . TALER_TURNSTILE_COOKIE] - ), - 'tags' => Cache::mergeTags( - $build['#cache']['tags'] ?? [], - $price_category->getCacheTags() - ), - ], - '#weight' => $build['#weight'] ?? 0, - ]; + // Strip the rendered field children but keep all the entity render + // metadata ('#entity_type', '#node', '#view_mode', '#theme', + // '#pre_render', ...). Downstream rendering — in particular the + // _title_callback in EntityViewController::buildTitle() — relies on + // '#entity_type', so blowing $build away with a fresh array crashes + // the page with "Undefined array key '#entity_type'". + foreach (array_keys($build) as $key) { + if ($key === '' || $key[0] !== '#') { + unset($build[$key]); + } + } + + // Page is identical for everyone *without* the cookie, so it + // remains cacheable. The cookie context only kicks in once the + // visitor has paid. + $build['#cache']['contexts'] = array_unique(array_merge( + $build['#cache']['contexts'] ?? [], + ['cookies:' . TALER_TURNSTILE_COOKIE] + )); + $build['#cache']['tags'] = Cache::mergeTags( + $build['#cache']['tags'] ?? [], + $price_category->getCacheTags() + ); $build['teaser'] = [ '#type' => 'container',