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:
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} ..."