commit b5ccb7ed5411b32b5fb5a0a5963f7ab60a169c0c
parent 2f6ef3d098daab8f0a5a0c64b15ac35f885fc22d
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 6 Aug 2026 14:50:24 +0200
/solve should consult pin_transmissions_left before declaring user out of options
Diffstat:
4 files changed, 232 insertions(+), 1 deletion(-)
diff --git a/src/challenger/challenger-httpd_solve.c b/src/challenger/challenger-httpd_solve.c
@@ -324,8 +324,12 @@ CH_handler_solve (struct CH_HandlerContext *hc,
enum MHD_Result ret;
json_t *details;
+ /* Only give up if *no* option remains: a /challenge retransmission
+ resets auth_attempts_left to 3, so as long as the user may still
+ request another PIN transmission they can still succeed. */
if ( (0 == bc->addr_left) &&
- (0 == bc->auth_attempts_left) )
+ (0 == bc->auth_attempts_left) &&
+ (0 == bc->pin_transmissions_left) )
{
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
"Client exhausted all chances to satisfy challenge\n");
diff --git a/src/challenger/meson.build b/src/challenger/meson.build
@@ -18,6 +18,7 @@ check_SCRIPTS = [
'test-challenger-token-errors',
'test-challenger-pinfail',
'test-challenger-resend',
+ 'test-challenger-exhaustion',
]
test_helper_cat = configure_file(input: 'cat.sh', output: 'cat.sh', copy: true)
@@ -52,6 +53,12 @@ test_helper_cat_once = configure_file(
copy: true,
)
+test_conf = configure_file(
+ input: 'test-challenger-exhaustion.conf',
+ output: 'test-challenger-exhaustion.conf',
+ copy: true,
+)
+
foreach s : check_SCRIPTS
tscript = '@0@.sh'.format(s)
test_exe = configure_file(input: tscript, output: tscript, copy: true)
diff --git a/src/challenger/test-challenger-exhaustion.conf b/src/challenger/test-challenger-exhaustion.conf
@@ -0,0 +1,18 @@
+[challenger]
+
+# Which external command should be used to transmit challenges?
+AUTH_COMMAND = cat.sh
+
+# What address type are we validating? (SMS, e-mail, etc.)
+ADDRESS_TYPE = file-access
+
+# Base URL
+BASE_URL = http://localhost/
+
+# Short cooldown so that the exhaustion test can exercise a PIN
+# retransmission without sleeping for minutes.
+PIN_RETRANSMISSION_FREQUENCY = 1 s
+
+[challengerdb-postgres]
+#The connection string the plugin has to use for connecting to the database
+CONFIG = postgres:///talercheck
diff --git a/src/challenger/test-challenger-exhaustion.sh b/src/challenger/test-challenger-exhaustion.sh
@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+# This file is in the public domain.
+#
+# Regression test for finding 11: '/solve' must not declare the user
+# permanently out of options while a PIN retransmission is still available.
+#
+# A '/challenge' retransmission resets auth_attempts_left to 3, so a
+# validation with pin_transmissions_left > 0 can still be completed even
+# when both the address budget and the current PIN's guess budget are
+# spent. Answering such a request with "users exhausted all possibilities
+# of passing the check" is wrong and the SPA presents it as terminal.
+#
+# The test drives a validation to address_attempts_left = 0,
+# auth_attempts_left = 0, pin_transmissions_left > 0, checks that the
+# terminal answer is *not* given, and then completes the validation via a
+# retransmission to prove that the user really was not out of options.
+
+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-exhaustion.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-exhaustion.conf"
+BURL="http://localhost:9967"
+REDIRECT_URI="http://client.example.com/"
+CLIENT_SECRET="secret-token:secret"
+CLIENT_ID=1
+
+echo -n "Initialize challenger database ..."
+challenger-dbinit -r -c "${CONF}" &> dbinit.log
+echo " OK"
+
+echo -n "Add challenger client ..."
+challenger-admin -c "${CONF}" -a "${CLIENT_SECRET}" "${REDIRECT_URI}" &> admin.log
+echo " OK"
+
+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"
+
+# Start a validation and get it into the 'address submitted' state.
+function new_validation() {
+ 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 "/setup: expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+ fi
+ NONCE=$(jq -r .nonce < "$LAST_RESPONSE")
+ 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}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+ if [ "$STATUS" != "200" ]
+ then
+ exit_fail "/authorize: expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+ fi
+}
+
+# submit_address $1=address-suffix; leaves the current PIN in $PIN
+function submit_address() {
+ rm -f "${FILENAME}"
+ STATUS=$(curl "${BURL}/challenge/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ --data-urlencode "filename=${FILENAME}" \
+ --data-urlencode "note=$1" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+ if [ "$STATUS" != "200" ]
+ then
+ exit_fail "/challenge: expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+ fi
+ # Every submission here transmits: changing the address refills the
+ # transmission budget and clears the cooldown, and the retransmission
+ # at the end waits the cooldown out.
+ PIN=$(awk '{print $5}' < "${FILENAME}")
+}
+
+# solve $1=pin; leaves the status in $STATUS
+function solve() {
+ STATUS=$(curl "${BURL}/solve/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ --data-urlencode "pin=$1" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+}
+
+# Burn all three guesses for the currently transmitted PIN.
+function burn_guesses() {
+ for i in 1 2 3
+ do
+ solve "$(( (10#${PIN} + i) % 100000000 ))"
+ if [ "$STATUS" = "200" ] || [ "$STATUS" = "302" ]
+ then
+ exit_fail "/solve accepted a wrong PIN!"
+ fi
+ done
+}
+
+echo -n "Driving validation to 'no addresses, no guesses, PIN left' ..."
+new_validation
+# Three address submissions: each one spends an address attempt (of the
+# three the validation starts with), transmits a PIN for the new address
+# and thus resets auth_attempts_left to 3, which burn_guesses then spends.
+# That leaves the validation with no addresses and no guesses, but with
+# PIN transmissions still to spare.
+for a in one two three
+do
+ submit_address "${a}"
+ burn_guesses
+done
+echo " OK"
+
+echo -n "Wrong PIN must not be answered as terminal exhaustion ..."
+solve "$(( (10#${PIN} + 1) % 100000000 ))"
+if grep -q "exhausted all possibilities" "$LAST_RESPONSE"
+then
+ exit_fail "/solve claims the user is out of options while a PIN retransmission is still available: $(cat $LAST_RESPONSE)"
+fi
+echo " OK"
+
+# Prove the user really was not out of options: retransmit and solve.
+echo -n "Retransmitting the PIN ..."
+sleep 2
+submit_address "three"
+echo " OK"
+
+echo -n "Solving with the retransmitted PIN ${PIN} ..."
+solve "${PIN}"
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "/solve: expected 200 OK after retransmission. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+TYPE=$(jq -r .type < "$LAST_RESPONSE")
+if [ "$TYPE" != "completed" ]
+then
+ exit_fail "/solve: expected 'completed'. Got: $TYPE" $(cat $LAST_RESPONSE)
+fi
+echo " OK"
+
+exit 0