challenger

OAuth 2.0-based authentication service that validates user can receive messages at a certain address
Log | Files | Refs | Submodules | README | LICENSE

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:
Msrc/challenger/challenger-httpd_challenge.c | 16+++++++++++++---
Msrc/challenger/meson.build | 1+
Asrc/challenger/test-challenger-resend.sh | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/challengerdb/do_challenge_address.c | 4++++
Msrc/challengerdb/do_challenge_address.sql | 7+++++++
Msrc/challengerdb/test_challenger_db.c | 2++
Msrc/include/challenger-database/do_challenge_address.h | 5+++++
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,