commit 2f6ef3d098daab8f0a5a0c64b15ac35f885fc22d
parent 5df615ac3647e50a71feb195c17b2ba3dc2b491b
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 6 Aug 2026 14:49:21 +0200
gate too many PINs (429) on pin_transmissions_left, not the guess counter
Diffstat:
7 files changed, 212 insertions(+), 3 deletions(-)
diff --git a/src/challenger/challenger-httpd_challenge.c b/src/challenger/challenger-httpd_challenge.c
@@ -141,11 +141,16 @@ struct ChallengeContext
uint32_t tan;
/**
- * How many attempts does the user have left?
+ * How many attempts does the user have left on the current PIN?
*/
uint32_t pin_attempts_left;
/**
+ * How many times may a PIN still be transmitted for this validation?
+ */
+ uint32_t pin_transmissions_left;
+
+ /**
* How did the helper die?
*/
enum GNUNET_OS_ProcessStatusType pst;
@@ -977,6 +982,7 @@ CH_handler_challenge (struct CH_HandlerContext *hc,
&bc->state,
&bc->last_tx_time,
&bc->pin_attempts_left,
+ &bc->pin_transmissions_left,
&bc->retransmit,
&bc->client_redirect_uri,
&bc->address_refused,
@@ -1043,10 +1049,14 @@ CH_handler_challenge (struct CH_HandlerContext *hc,
"client exceeded authorization attempts limit (too many addresses attempted)");
}
- if (0 == bc->pin_attempts_left)
+ if ( (! bc->retransmit) &&
+ (0 == bc->pin_transmissions_left) )
{
+ /* Note: only the *transmission* budget being exhausted is terminal.
+ If merely the cooldown blocks the transmission, we fall through and
+ report "transmitted":false with the retransmission_time. */
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
- "Address changes exhausted PIN limit for this address\n");
+ "PIN transmission limit exhausted for this validation\n");
return reply_error (bc,
MHD_HTTP_TOO_MANY_REQUESTS,
TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS,
diff --git a/src/challenger/meson.build b/src/challenger/meson.build
@@ -17,6 +17,7 @@ check_SCRIPTS = [
'test-challenger-db-retry',
'test-challenger-token-errors',
'test-challenger-pinfail',
+ 'test-challenger-resend',
]
test_helper_cat = configure_file(input: 'cat.sh', output: 'cat.sh', copy: true)
diff --git a/src/challenger/test-challenger-resend.sh b/src/challenger/test-challenger-resend.sh
@@ -0,0 +1,180 @@
+#!/usr/bin/env bash
+# This file is in the public domain.
+#
+# Tests that asking for a new PIN during the retransmission cooldown is
+# answered with 200 and "transmitted":false, and not with a terminal
+# "too many PINs" 429.
+#
+# Regression test: the handler stored the *guess* counter
+# (auth_attempts_left) in bc->pin_attempts_left and read 0 as "the PIN
+# transmission quota is exhausted", so after three wrong PINs the very
+# next resend request was refused with 429 -- even though two PIN
+# transmissions were still available.
+
+set -eu
+
+# Exit, with status code "skip" (no 'real' failure)
+function exit_skip() {
+ echo " SKIP: $1"
+ exit 77
+}
+
+# Exit, with error message (hard failure)
+function exit_fail() {
+ echo " FAIL: $@"
+ exit 1
+}
+
+# Cleanup to run whenever we exit
+function cleanup()
+{
+ for n in $(jobs -p)
+ do
+ kill $n 2> /dev/null || true
+ done
+ rm -f "$LAST_RESPONSE" "$FILENAME"
+ wait
+}
+
+LAST_RESPONSE=$(mktemp responseXXXXXX.log)
+FILENAME="test-challenger-resend.txt"
+
+# Install cleanup handler (except for kill -9)
+trap cleanup EXIT
+
+export PATH="$PATH:."
+
+echo -n "Testing for jq"
+jq -h > /dev/null || exit_skip "jq required"
+echo " FOUND"
+echo -n "Testing for curl"
+curl -h > /dev/null || exit_skip "curl required"
+echo " FOUND"
+echo -n "Testing for wget"
+wget -h > /dev/null || exit_skip "wget required"
+echo " FOUND"
+echo -n "Testing for challenger-httpd ..."
+challenger-httpd -h > /dev/null || exit_skip "challenger-httpd required"
+echo " FOUND"
+
+CONF="test-challenger.conf"
+BURL="http://localhost:9967"
+REDIRECT_URI="http://client.example.com/"
+
+echo -n "Initialize challenger database ..."
+challenger-dbinit -r -c "${CONF}" &> dbinit.log
+echo " OK"
+
+echo -n "Add challenger client ..."
+CLIENT_SECRET="secret-token:secret"
+challenger-admin -c "${CONF}" -a "${CLIENT_SECRET}" "${REDIRECT_URI}" &> admin.log
+echo " OK"
+# We just reset the DB, thus the client ID must be 1 here:
+CLIENT_ID=1
+
+echo -n "Start challenger-httpd ..."
+challenger-httpd -L INFO -c "${CONF}" &> httpd.log &
+
+# Wait for challenger to be available
+for n in $(seq 1 50)
+do
+ echo -n "."
+ sleep 0.2
+ OK=0
+ wget --tries=1 --timeout=1 "${BURL}/config" -o /dev/null -O /dev/null >/dev/null || continue
+ OK=1
+ break
+done
+if [ 1 != $OK ]
+then
+ exit_skip "Failed to launch challenger service"
+fi
+echo " OK"
+
+echo -n "Setup new validation process..."
+STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \
+ -H "Authorization: Bearer ${CLIENT_SECRET}" \
+ -d '' \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+NONCE=$(jq -r .nonce < "$LAST_RESPONSE")
+echo " OK"
+
+CLIENT_STATE="the-client-state"
+CLIENT_SCOPE="the-client-scope"
+
+echo -n "Initiating user login..."
+STATUS=$(curl "${BURL}/authorize/${NONCE}" \
+ -G \
+ -H "Accept: application/json" \
+ --data-urlencode "response_type=code" \
+ --data-urlencode "client_id=${CLIENT_ID}" \
+ --data-urlencode "redirect_uri=${REDIRECT_URI}" \
+ --data-urlencode "state=${CLIENT_STATE}" \
+ --data-urlencode "scope=${CLIENT_SCOPE}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+echo " OK"
+
+echo -n "Initiating address submission..."
+STATUS=$(curl "${BURL}/challenge/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ --data-urlencode "filename=${FILENAME}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+echo " OK"
+
+PIN=$(cat ${FILENAME} | awk '{print $5}')
+WRONG=$(( (PIN + 1) % 100000000 ))
+
+echo -n "Burning the three PIN guesses..."
+for attempt in 1 2 3
+do
+ STATUS=$(curl "${BURL}/solve/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ --data-urlencode "pin=${WRONG}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+ if [ "$STATUS" != "403" ]
+ then
+ exit_fail "Expected 403 for a wrong PIN. Got: $STATUS" $(cat $LAST_RESPONSE)
+ fi
+ echo -n "."
+done
+echo " OK"
+
+# The user now asks for a fresh PIN, but the retransmission cooldown has
+# not elapsed. Two PIN transmissions are still available, so this is a
+# temporary "not now", not "you are out of PINs".
+echo -n "Requesting a new PIN inside the cooldown..."
+STATUS=$(curl "${BURL}/challenge/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ --data-urlencode "filename=${FILENAME}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK (cooldown, not exhaustion). Got: $STATUS" \
+ $(cat $LAST_RESPONSE)
+fi
+if [ "false" != "$(jq -r '.transmitted' < "$LAST_RESPONSE")" ]
+then
+ exit_fail "Expected transmitted:false. Got:" $(cat $LAST_RESPONSE)
+fi
+echo " OK"
+
+exit 0
diff --git a/src/challengerdb/do_challenge_address.c b/src/challengerdb/do_challenge_address.c
@@ -36,6 +36,7 @@ CHALLENGERDB_do_challenge_address (
char **state,
struct GNUNET_TIME_Absolute *last_tx_time,
uint32_t *auth_attempts_left,
+ uint32_t *pin_transmissions_left,
bool *pin_transmit,
char **client_redirect_uri,
bool *address_refused,
@@ -79,6 +80,8 @@ CHALLENGERDB_do_challenge_address (
pin_transmit),
GNUNET_PQ_result_spec_uint32 ("auth_attempts_left",
auth_attempts_left),
+ GNUNET_PQ_result_spec_uint32 ("pin_transmissions_left",
+ pin_transmissions_left),
GNUNET_PQ_result_spec_allow_null (
GNUNET_PQ_result_spec_string ("client_redirect_uri",
client_redirect_uri),
@@ -105,6 +108,7 @@ CHALLENGERDB_do_challenge_address (
",out_last_pin AS last_pin"
",out_state AS state"
",out_auth_attempts_left AS auth_attempts_left"
+ ",out_pin_transmissions_left AS pin_transmissions_left"
",out_client_redirect_uri AS client_redirect_uri"
",out_address_refused AS address_refused"
",out_solved AS solved"
diff --git a/src/challengerdb/do_challenge_address.sql b/src/challengerdb/do_challenge_address.sql
@@ -31,6 +31,10 @@ CREATE FUNCTION challenger_do_challenge_set_address_and_pin (
OUT out_state TEXT,
OUT out_pin_transmit BOOLEAN,
OUT out_auth_attempts_left INT4,
+ -- How many PIN transmissions are left *after* this call? Note that
+ -- this is a different budget from out_auth_attempts_left, which counts
+ -- the guesses the user has on the current PIN.
+ OUT out_pin_transmissions_left INT4,
OUT out_client_redirect_uri TEXT,
OUT out_address_refused BOOLEAN,
OUT out_solved BOOLEAN)
@@ -66,6 +70,7 @@ THEN
out_last_pin=0;
out_pin_transmit=FALSE;
out_auth_attempts_left=0;
+ out_pin_transmissions_left=0;
out_client_redirect_uri=NULL;
out_address_refused=TRUE;
out_solved=FALSE;
@@ -77,6 +82,7 @@ out_last_tx_time=my_status.last_tx_time;
out_last_pin=my_status.last_pin;
out_pin_transmit=FALSE;
out_auth_attempts_left=my_status.auth_attempts_left;
+out_pin_transmissions_left=my_status.pin_transmissions_left;
out_state=my_status.client_state;
out_client_redirect_uri=my_status.client_redirect_uri;
@@ -157,6 +163,7 @@ THEN
my_status.pin_transmissions_left = my_status.pin_transmissions_left - 1;
my_status.pending_pin = in_tan;
my_status.last_tx_time = in_now;
+ out_pin_transmissions_left = my_status.pin_transmissions_left;
out_auth_attempts_left = 3;
out_pin_transmit=TRUE;
out_last_pin = in_tan;
diff --git a/src/challengerdb/test_challenger_db.c b/src/challengerdb/test_challenger_db.c
@@ -113,6 +113,7 @@ challenge_validation (struct CHALLENGER_ValidationNonceP *nonce,
char *redirect_uri = NULL;
struct GNUNET_TIME_Absolute last_tx_time;
uint32_t auth_attempts_left;
+ uint32_t pin_transmissions_left;
bool pin_transmit;
bool address_refused;
bool solved;
@@ -144,6 +145,7 @@ challenge_validation (struct CHALLENGER_ValidationNonceP *nonce,
&state,
&last_tx_time,
&auth_attempts_left,
+ &pin_transmissions_left,
&pin_transmit,
&redirect_uri,
&address_refused,
diff --git a/src/include/challenger-database/do_challenge_address.h b/src/include/challenger-database/do_challenge_address.h
@@ -48,6 +48,10 @@
* @param[out] last_tx_time set to the last time when we (presumably) send a PIN to @a address
* @param[out] pin_transmit set to true if we should transmit the @a last_pin to the @a address
* @param[out] auth_attempts_left set to number of attempts the user has left on this pin
+ * @param[out] pin_transmissions_left set to number of times a PIN may still be
+ * transmitted for this validation; 0 means the user cannot request
+ * another PIN, while @a auth_attempts_left being 0 only means that the
+ * guesses on the *current* PIN are used up
* @param[out] client_redirect_uri redirection URI of the client (for reporting failures)
* @param[out] address_refused set to true if the address was refused (address change attempts exhausted)
* @param[out] solved set to true if the challenge is already solved
@@ -68,6 +72,7 @@ CHALLENGERDB_do_challenge_address (
char **state,
struct GNUNET_TIME_Absolute *last_tx_time,
uint32_t *auth_attempts_left,
+ uint32_t *pin_transmissions_left,
bool *pin_transmit,
char **client_redirect_uri,
bool *address_refused,