challenger

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

commit 0ed94333a35a9f22c6b18c9cf96a0d9bde404ff6
parent b5ccb7ed5411b32b5fb5a0a5963f7ab60a169c0c
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 14:51:31 +0200

/solve should properly distinguish causes of wrong PIN (403, 409, 429) in its response

Diffstat:
Msrc/challenger/challenger-httpd_solve.c | 43+++++++++++++++++++++++++++++++++++++++----
Msrc/challenger/test-challenger-exhaustion.conf | 7+++++--
Msrc/challenger/test-challenger-exhaustion.sh | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
3 files changed, 164 insertions(+), 16 deletions(-)

diff --git a/src/challenger/challenger-httpd_solve.c b/src/challenger/challenger-httpd_solve.c @@ -323,6 +323,8 @@ CH_handler_solve (struct CH_HandlerContext *hc, { enum MHD_Result ret; json_t *details; + unsigned int http_status; + enum TALER_ErrorCode ec; /* Only give up if *no* option remains: a /challenge retransmission resets auth_attempts_left to 3, so as long as the user may still @@ -340,12 +342,45 @@ CH_handler_solve (struct CH_HandlerContext *hc, "users exhausted all possibilities of passing the check"); } - GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "Invalid PIN supplied\n"); + /* Distinguish the three ways in which a /solve can fail. Only the + last one is actually about the PIN that was submitted; reporting + the other two as "the PIN code provided is incorrect" misleads + the user, and /challenge already answers the analogous conditions + with 429 #TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS. */ + if (no_challenge) + { + /* No PIN was ever transmitted for this validation, so there is + nothing to check; the user must POST /challenge first. */ + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "PIN submitted before any challenge was transmitted\n"); + http_status = MHD_HTTP_CONFLICT; + ec = TALER_EC_CHALLENGER_MISSING_ADDRESS; + } + else if (exhausted) + { + /* The PIN was not even looked at: no attempts left for it. The + user may still request a retransmission (otherwise we would + have taken the terminal branch above). */ + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "No attempts left to check the PIN\n"); + http_status = MHD_HTTP_TOO_MANY_REQUESTS; + ec = TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS; + } + else + { + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Invalid PIN supplied\n"); + http_status = MHD_HTTP_FORBIDDEN; + ec = TALER_EC_CHALLENGER_INVALID_PIN; + /* The stored procedure only reports 'exhausted' on the request + *after* the last attempt was consumed; tell the client already + on the response that consumed it. */ + exhausted = (0 == bc->auth_attempts_left); + } details = GNUNET_JSON_PACK ( GNUNET_JSON_pack_string ("type", "pending"), - TALER_JSON_pack_ec (TALER_EC_CHALLENGER_INVALID_PIN), + TALER_JSON_pack_ec (ec), GNUNET_JSON_pack_uint64 ("addresses_left", bc->addr_left), GNUNET_JSON_pack_uint64 ("pin_transmissions_left", @@ -359,7 +394,7 @@ CH_handler_solve (struct CH_HandlerContext *hc, ); ret = TALER_MHD_reply_json (hc->connection, details, - MHD_HTTP_FORBIDDEN); + http_status); json_decref (details); return ret; } diff --git a/src/challenger/test-challenger-exhaustion.conf b/src/challenger/test-challenger-exhaustion.conf @@ -10,8 +10,11 @@ ADDRESS_TYPE = file-access 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 +# retransmission without sleeping for minutes: the test waits the cooldown +# out once, to re-transmit a PIN for an address it does not change. Do +# not go below 1 s: with a sub-second frequency +# '/authorize' aborts the daemon on an unguarded GNUNET_JSON_pack_timestamp. +PIN_RETRANSMISSION_FREQUENCY = 5 s [challengerdb-postgres] #The connection string the plugin has to use for connecting to the database diff --git a/src/challenger/test-challenger-exhaustion.sh b/src/challenger/test-challenger-exhaustion.sh @@ -14,6 +14,19 @@ # 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. +# +# Reaching that state exploits that the two budgets are independent: the +# three address attempts of a validation each come with three PIN +# transmissions, so spending every address attempt and every guess on the +# PINs they transmitted still leaves transmissions unspent. +# +# Also a regression test for finding 12: '/solve' must not answer every +# failure with 403 #TALER_EC_CHALLENGER_INVALID_PIN ("the PIN code +# provided is incorrect"). Exhaustion is reported the way /challenge +# reports it (429 #TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS), submitting a +# PIN before any challenge was transmitted is reported as a conflict, and +# 'exhausted' is set on the response that consumes the last attempt +# rather than on the one after it. set -eu @@ -118,7 +131,9 @@ function new_validation() { fi } -# submit_address $1=address-suffix; leaves the current PIN in $PIN +# submit_address $1=address-suffix. Leaves the freshly transmitted PIN in +# $PIN, whether a PIN was transmitted at all in $TRANSMITTED and the time +# from which the next transmission is allowed in $RETRANSMISSION_TIME. function submit_address() { rm -f "${FILENAME}" STATUS=$(curl "${BURL}/challenge/${NONCE}" \ @@ -131,10 +146,36 @@ function submit_address() { then exit_fail "/challenge: expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE) fi + TRANSMITTED=$(jq -r .transmitted < "$LAST_RESPONSE") + RETRANSMISSION_TIME=$(jq -r .retransmission_time.t_s < "$LAST_RESPONSE") # 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}") + # at the end waits the cooldown out. Still, only pick up a PIN that + # was really sent, so that expect_transmitted() below reports the + # mismatch rather than awk reporting a missing file. + if [ "$TRANSMITTED" = "true" ] + then + PIN=$(awk '{print $5}' < "${FILENAME}") + fi +} + +# expect_transmitted $1=true|false $2=what was expected to (not) transmit +function expect_transmitted() { + if [ "$TRANSMITTED" != "$1" ] + then + exit_fail "/challenge: expected transmitted=$1 for $2. Got: $(cat $LAST_RESPONSE)" + fi +} + +# Sleep until the cooldown of the last transmission has passed, so that the +# next /challenge is guaranteed to (re)transmit. +function await_retransmission() { + local delta + delta=$(( RETRANSMISSION_TIME - $(date +%s) + 1 )) + if [ "${delta}" -gt 0 ] + then + sleep "${delta}" + fi } # solve $1=pin; leaves the status in $STATUS @@ -155,22 +196,78 @@ function burn_guesses() { then exit_fail "/solve accepted a wrong PIN!" fi + # A wrong PIN really is a wrong PIN: 403 + CHALLENGER_INVALID_PIN. + if [ "$STATUS" != "403" ] + then + exit_fail "/solve: expected 403 for a wrong PIN. Got: $STATUS" $(cat $LAST_RESPONSE) + fi + CODE=$(jq -r .code < "$LAST_RESPONSE") + if [ "$CODE" != "9758" ] + then + exit_fail "/solve: expected code 9758 (INVALID_PIN) for a wrong PIN. Got: $CODE" + fi + EXHAUSTED=$(jq -r .exhausted < "$LAST_RESPONSE") + LEFT=$(jq -r .auth_attempts_left < "$LAST_RESPONSE") + if [ "$i" = "3" ] + then + # this very response consumed the last attempt + if [ "$LEFT" != "0" ] || [ "$EXHAUSTED" != "true" ] + then + exit_fail "/solve: expected auth_attempts_left=0 and exhausted=true on the response consuming the last attempt. Got: $(cat $LAST_RESPONSE)" + fi + else + if [ "$EXHAUSTED" != "false" ] + then + exit_fail "/solve: unexpected exhausted=true with ${LEFT} attempts left" + fi + fi done } +echo -n "PIN submitted before any challenge was transmitted ..." +new_validation +solve "12345678" +if [ "$(jq -r .no_challenge < "$LAST_RESPONSE")" != "true" ] +then + exit_fail "/solve: expected no_challenge=true. Got: $(cat $LAST_RESPONSE)" +fi +CODE=$(jq -r .code < "$LAST_RESPONSE") +if [ "$CODE" = "9758" ] +then + exit_fail "/solve claims 'the PIN code provided is incorrect' although no PIN was ever sent" +fi +if [ "$STATUS" != "409" ] || [ "$CODE" != "9759" ] +then + exit_fail "/solve: expected 409 with code 9759. Got: $STATUS / $CODE" $(cat $LAST_RESPONSE) +fi +echo " OK" + 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. +# Each of the three address submissions spends one of the three address +# attempts and, because an address change refills the transmission budget +# and clears the cooldown, transmits a PIN for the new address -- which +# sets auth_attempts_left to 3, and burn_guesses spends those three +# guesses again. After the third round the validation has no addresses +# and no guesses left, but PIN transmissions to spare. for a in one two three do submit_address "${a}" + expect_transmitted "true" "the submission of address '${a}'" burn_guesses done -echo " OK" +# Fail loudly rather than testing something weaker should the budget +# semantics change again. +ADDRESSES_LEFT=$(jq -r .addresses_left < "$LAST_RESPONSE") +TRANSMISSIONS_LEFT=$(jq -r .pin_transmissions_left < "$LAST_RESPONSE") +ATTEMPTS_LEFT=$(jq -r .auth_attempts_left < "$LAST_RESPONSE") +if [ "$ADDRESSES_LEFT" != "0" ] || \ + [ "$ATTEMPTS_LEFT" != "0" ] || \ + [ "$TRANSMISSIONS_LEFT" -lt 1 ] +then + exit_fail "expected addresses_left=0, auth_attempts_left=0 and pin_transmissions_left>0. Got: $(cat $LAST_RESPONSE)" +fi +echo " OK (addresses_left=${ADDRESSES_LEFT}, auth_attempts_left=${ATTEMPTS_LEFT}, pin_transmissions_left=${TRANSMISSIONS_LEFT})" echo -n "Wrong PIN must not be answered as terminal exhaustion ..." solve "$(( (10#${PIN} + 1) % 100000000 ))" @@ -180,10 +277,23 @@ then fi echo " OK" +echo -n "Exhaustion must be reported like /challenge reports it ..." +CODE=$(jq -r .code < "$LAST_RESPONSE") +if [ "$STATUS" != "429" ] || [ "$CODE" != "9757" ] +then + exit_fail "/solve: expected 429 with code 9757 (TOO_MANY_ATTEMPTS) once the guesses are spent. Got: $STATUS / $CODE" $(cat $LAST_RESPONSE) +fi +if [ "$(jq -r .exhausted < "$LAST_RESPONSE")" != "true" ] +then + exit_fail "/solve: expected exhausted=true. Got: $(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 +await_retransmission submit_address "three" +expect_transmitted "true" "the retransmission" echo " OK" echo -n "Solving with the retransmitted PIN ${PIN} ..."