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:
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',