challenger

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

commit 5df615ac3647e50a71feb195c17b2ba3dc2b491b
parent f857c916e86f2ff4ff12f8fed51646ebb2f59082
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 14:47:55 +0200

more precise PIN state management: distinguish between PINs that were actually sent and those that we are trying to send in the DB

Diffstat:
Asrc/challenger/cat-once.sh | 14++++++++++++++
Msrc/challenger/challenger-httpd_challenge.c | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/challenger/meson.build | 13+++++++++++++
Asrc/challenger/test-challenger-pinfail.conf | 21+++++++++++++++++++++
Asrc/challenger/test-challenger-pinfail.sh | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/challenger/test-challenger-pkce-downgrade.sh | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/challenger/test-challenger-token-errors.sh | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/challengerdb/challenger-0005.sql | 34++++++++++++++++++++++++++++++++++
Msrc/challengerdb/do_challenge_address.c | 36++++++++++++++++++++++++++++++++++++
Msrc/challengerdb/do_challenge_address.sql | 24++++++++++++++++++++----
Msrc/challengerdb/meson.build | 1+
Msrc/challengerdb/test_challenger_db.c | 10++++++++++
Msrc/include/challenger-database/do_challenge_address.h | 31+++++++++++++++++++++++++++++++
13 files changed, 917 insertions(+), 4 deletions(-)

diff --git a/src/challenger/cat-once.sh b/src/challenger/cat-once.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# This file is in the public domain. +# +# Like cat.sh, but only for the first challenge: every later invocation +# fails without delivering anything, simulating an SMS/e-mail gateway +# that went down between two PIN transmissions. +TARGET="$(echo $1 | jq -r ".filename")" +if [ -e "${TARGET}.sent" ] +then + exit 1 +fi +touch "${TARGET}.sent" +cat - > "${TARGET}" +exit 0 diff --git a/src/challenger/challenger-httpd_challenge.c b/src/challenger/challenger-httpd_challenge.c @@ -187,6 +187,12 @@ struct ChallengeContext bool db_finished; /** + * True if we handed a new PIN to the transmission helper and it still + * needs to be confirmed in the database once the helper succeeded. + */ + bool pin_pending; + + /** * Is the upload in JSON? */ bool is_json; @@ -491,6 +497,9 @@ send_tan (struct ChallengeContext *bc) bc->cwh = GNUNET_wait_child (bc->child, &child_done_cb, bc); + /* The PIN is now in the hands of the helper; if it terminates + successfully, the main handler promotes it in the database. */ + bc->pin_pending = true; MHD_suspend_connection (bc->hc->connection); bc->suspended = GNUNET_YES; GNUNET_CONTAINER_DLL_insert (bc_head, @@ -1064,6 +1073,49 @@ CH_handler_challenge (struct CH_HandlerContext *hc, } } + if (bc->pin_pending) + { + /* The helper terminated successfully (checked above), so the PIN really + went out and only now becomes the PIN we accept. Had the helper + failed, we returned 502 above and the previous PIN (if any) remains + valid. */ + bc->pin_pending = false; + for (unsigned int r = 0; r<MAX_RETRIES; r++) + { + enum GNUNET_DB_QueryStatus qs; + + qs = CHALLENGERDB_do_challenge_address_confirm_pin ( + CH_context, + &bc->nonce, + &bc->pin_attempts_left); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + return reply_error (bc, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "do_challenge_address_confirm_pin"); + case GNUNET_DB_STATUS_SOFT_ERROR: + if (r < MAX_RETRIES - 1) + continue; + GNUNET_break (0); + return reply_error (bc, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "do_challenge_address_confirm_pin"); + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + /* Validation was solved or removed while we were transmitting; + there is nothing to promote, but the PIN did go out. */ + GNUNET_break_op (0); + break; + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + } + break; + } + } + { json_t *args; struct MHD_Response *resp; diff --git a/src/challenger/meson.build b/src/challenger/meson.build @@ -16,6 +16,7 @@ check_SCRIPTS = [ 'test-challenger-pinlimit', 'test-challenger-db-retry', 'test-challenger-token-errors', + 'test-challenger-pinfail', ] test_helper_cat = configure_file(input: 'cat.sh', output: 'cat.sh', copy: true) @@ -38,6 +39,18 @@ test_conf = configure_file( copy: true, ) +test_conf = configure_file( + input: 'test-challenger-pinfail.conf', + output: 'test-challenger-pinfail.conf', + copy: true, +) + +test_helper_cat_once = configure_file( + input: 'cat-once.sh', + output: 'cat-once.sh', + 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-pinfail.conf b/src/challenger/test-challenger-pinfail.conf @@ -0,0 +1,21 @@ +[challenger] + +# Delivers the first PIN and then fails, see cat-once.sh. +AUTH_COMMAND = cat-once.sh + +# What address type are we validating? (SMS, e-mail, etc.) +ADDRESS_TYPE = file-access + +# Base URL +BASE_URL = http://localhost/ + +# Minimal cooldown between (re)transmissions, so that the test can ask +# for a retransmission after a short sleep. Do not lower this below 1s: +# /authorize reports 'last_tx_time + PIN_RETRANSMISSION_FREQUENCY' as a +# timestamp, and timestamps are truncated to seconds -- a zero timestamp +# cannot be packed into the reply. +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-pinfail.sh b/src/challenger/test-challenger-pinfail.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# This file is in the public domain. +# +# Tests that a failed PIN transmission does not invalidate the PIN the +# user already received. +# +# Regression test: /challenge used to commit the new PIN (overwriting +# last_pin) before forking the AUTH_COMMAND helper, so a helper failure +# returned 502 *and* left the user without a usable PIN until the +# retransmission cooldown elapsed. + +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" "$FILENAME.sent" + rm -f "$FILENAME2" "$FILENAME2.sent" + wait +} + +LAST_RESPONSE=$(mktemp responseXXXXXX.log) +FILENAME="test-challenger-pinfail.txt" +FILENAME2="test-challenger-pinfail-changed.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-pinfail.conf" +BURL="http://localhost:9967" +REDIRECT_URI="http://client.example.com/" + +rm -f "$FILENAME" "$FILENAME.sent" +rm -f "$FILENAME2" "$FILENAME2.sent" + +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}') + +# The helper now refuses to deliver anything (see cat-once.sh). +sleep 1.5 +echo -n "Requesting a retransmission that the helper will fail..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data-urlencode "filename=${FILENAME}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$STATUS" != "502" ] +then + exit_fail "Expected 502 Bad Gateway. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +echo " OK" + +# The PIN the user got in the first message must still work: the PIN of +# the failed transmission never reached anybody. +echo -n "Solving with the PIN ${PIN} from the delivered message..." +RESULT=$(curl "${BURL}/solve/${NONCE}" \ + -X POST \ + -H "Accept: text/html" \ + --data-urlencode "pin=${PIN}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$RESULT" != "302" ] +then + exit_fail "Expected 302 (PIN still valid). Got: $RESULT" $(cat $LAST_RESPONSE) +fi +echo " OK" + +# The flip side: deferring the new PIN must not keep the *old* PIN alive +# across an address change. A changed address always (re)transmits -- the +# change refills the transmission budget and clears the cooldown -- so +# before this patch the PIN on file was replaced in the same call. Now it +# is only replaced once the helper confirms, and a helper failure must +# therefore leave the validation with no usable PIN rather than with the +# PIN that went to the address the user just replaced. + +echo -n "Setup a second 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" + +echo -n "Transmitting a PIN for the first address..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data-urlencode "filename=${FILENAME2}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +OLD_PIN=$(awk '{print $5}' < "${FILENAME2}") +echo " OK" + +# The helper refuses to deliver anything from here on (see cat-once.sh); +# the address change transmits regardless of the cooldown. +echo -n "Changing the address while the helper is down..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data-urlencode "filename=${FILENAME2}" \ + --data-urlencode "tag=corrected" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$STATUS" != "502" ] +then + exit_fail "Expected 502 Bad Gateway. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +echo " OK" + +echo -n "The PIN of the previous address must not solve the new one..." +RESULT=$(curl "${BURL}/solve/${NONCE}" \ + -X POST \ + -H "Accept: text/html" \ + --data-urlencode "pin=${OLD_PIN}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$RESULT" = "302" ] || [ "$RESULT" = "200" ] +then + exit_fail "the PIN sent for the previous address attested the new one" +fi +echo " OK ($RESULT)" + +exit 0 diff --git a/src/challenger/test-challenger-pkce-downgrade.sh b/src/challenger/test-challenger-pkce-downgrade.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# This file is in the public domain. +# +# Regression test: a second /authorize must not be able to *remove* the PKCE +# code challenge of a validation that already has one. /authorize +# authenticates nobody and the nonce is recoverable from an issued code, so +# stripping the binding would let anyone who learns the nonce turn a +# PKCE-protected authorization into an unprotected one. + +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-pkce-downgrade.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-pkce.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 run this test on a fresh database, 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 -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" +CODE_CHALLENGE_METHOD="S256" +CODE_CHALLENGE="3FtH5SoMllpyFP-nlcdmkGYraTpnhA-9U1N6tHoUYv8" +CODE_VERIFIER="z167JIUt0F.II.qLPlCaXmL8BI6x9E-qqHAE_xEO_8p" + +echo -n "Initiating user login with code_challenge..." +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}" \ + --data-urlencode "code_challenge_method=${CODE_CHALLENGE_METHOD}" \ + --data-urlencode "code_challenge=${CODE_CHALLENGE}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +echo "OK" + +# This is the attack: the very same (unauthenticated) request again, but +# without any PKCE arguments. It is allowed to happen, it may just not +# discard the code challenge that is already on file. +echo -n "Replaying user login without code_challenge..." +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}') + +echo -n "Initiating PIN ${PIN} submission..." +RESULT=$(curl "${BURL}/solve/${NONCE}" \ + -X POST \ + -H "Accept: text/html" \ + --data-urlencode "pin=${PIN}" \ + -w "%{http_code} %{redirect_url}" -s -o $LAST_RESPONSE) +STATUS=$(echo "$RESULT" | awk '{print $1}') +TARGET=$(echo "$RESULT" | awk '{print $2}') + +if [ "$STATUS" != "302" ] +then + exit_fail "Expected 302. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +TCODE=$(echo "$TARGET" | sed -e "s/.*?code=//g" -e "s/&.*//g") +echo "OK" + +echo -n "Requesting authorization without code_verifier ..." +STATUS=$(curl "${BURL}/token" \ + -X POST \ + --data-urlencode "client_id=${CLIENT_ID}" \ + --data-urlencode "redirect_uri=${REDIRECT_URI}" \ + --data-urlencode "client_secret=${CLIENT_SECRET}" \ + --data-urlencode "code=${TCODE}" \ + --data-urlencode "grant_type=authorization_code" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$STATUS" != "400" ] +then + exit_fail "PKCE binding was downgraded: expected 400 without code_verifier, got: $STATUS" $(cat $LAST_RESPONSE) +fi +echo "OK" + +echo -n "Requesting authorization with the original code_verifier ..." +STATUS=$(curl "${BURL}/token" \ + -X POST \ + --data-urlencode "client_id=${CLIENT_ID}" \ + --data-urlencode "redirect_uri=${REDIRECT_URI}" \ + --data-urlencode "client_secret=${CLIENT_SECRET}" \ + --data-urlencode "code=${TCODE}" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code_verifier=${CODE_VERIFIER}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +TOKEN_TYPE="$(cat $LAST_RESPONSE | jq -r .token_type)" +if [ "$TOKEN_TYPE" != "Bearer" ] +then + exit_fail "Expected Bearer token. Got: $TOKEN_TYPE" +fi +echo "OK" + +exit 0 diff --git a/src/challenger/test-challenger-token-errors.sh b/src/challenger/test-challenger-token-errors.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# This file is in the public domain. +# +# Regression test: /token must not be an oracle for the state of a +# validation. The nonce is trivially recovered from any authorization +# code (CH_code_to_nonce() just parses the part before the '-'), so every +# check that runs before the code MAC is verified answers a question about +# a validation the caller may not hold a code for. +# +# Three requests that all carry a *forged* code must therefore be answered +# identically: +# 1. a validation that exists but has no proven address yet, +# 2. a nonce that is syntactically valid but unknown, +# 3. a validation that was solved (so only the MAC is wrong). +# +# Before the fix these produced 409/invalid_request/9759, +# 400/invalid_grant/9755 and 400/invalid_grant/9754 respectively. + +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-token-errors.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 + +CLIENT_STATE="the-client-state" +CLIENT_SCOPE="the-client-scope" + +# The MAC half of an authorization code is never parsed, only compared, +# so any syntactically plausible value serves as a forgery. +FORGED_MAC="00000000000000000000000000000000000000000000000000000" + +# Post a code to /token and store "<status> <body>" in TOKEN_RESULT. +function post_token() +{ + local STATUS + STATUS=$(curl "${BURL}/token" \ + -X POST \ + --data-urlencode "client_id=${CLIENT_ID}" \ + --data-urlencode "redirect_uri=${REDIRECT_URI}" \ + --data-urlencode "client_secret=${CLIENT_SECRET}" \ + --data-urlencode "code=$1" \ + --data-urlencode "grant_type=authorization_code" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + TOKEN_RESULT="${STATUS} $(jq -cS . < "$LAST_RESPONSE")" +} + +# Run /setup and /authorize, leaving the nonce in NONCE. +function start_validation() +{ + local STATUS + 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 from /setup. 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}" \ + --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 from /authorize. Got: $STATUS" $(cat $LAST_RESPONSE) + fi +} + + +echo -n "Redeeming a forged code for a validation without address..." +start_validation +NO_ADDRESS_NONCE="${NONCE}" +post_token "${NO_ADDRESS_NONCE}-${FORGED_MAC}" +NO_ADDRESS_RESULT="${TOKEN_RESULT}" +case "${NO_ADDRESS_RESULT}" in + 400\ *) + # Good! + ;; + *) + exit_fail "Expected 400 for a forged code, got: ${NO_ADDRESS_RESULT}" + ;; +esac +echo " OK" + +echo -n "Redeeming a forged code for an unknown nonce..." +# Mangle the first character; the trailing bits of the last one are +# padding, so only the leading character can be changed safely. +case "${NO_ADDRESS_NONCE}" in + X*) UNKNOWN_NONCE="Y${NO_ADDRESS_NONCE:1}" ;; + *) UNKNOWN_NONCE="X${NO_ADDRESS_NONCE:1}" ;; +esac +post_token "${UNKNOWN_NONCE}-${FORGED_MAC}" +UNKNOWN_RESULT="${TOKEN_RESULT}" +echo " OK" + +echo -n "Redeeming a forged code for a solved validation..." +start_validation +SOLVED_NONCE="${NONCE}" +STATUS=$(curl "${BURL}/challenge/${SOLVED_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 from /challenge. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +PIN=$(cat ${FILENAME} | awk '{print $5}') +RESULT=$(curl "${BURL}/solve/${SOLVED_NONCE}" \ + -X POST \ + -H "Accept: text/html" \ + --data-urlencode "pin=${PIN}" \ + -w "%{http_code} %{redirect_url}" -s -o $LAST_RESPONSE) +STATUS=$(echo "$RESULT" | awk '{print $1}') +if [ "$STATUS" != "302" ] +then + exit_fail "Expected 302 from /solve. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +TCODE=$(echo "$RESULT" | awk '{print $2}' | sed -e "s/.*?code=//g" -e "s/&.*//g") +post_token "${SOLVED_NONCE}-${FORGED_MAC}" +BAD_MAC_RESULT="${TOKEN_RESULT}" +echo " OK" + +echo -n "Checking /token does not distinguish the three failures..." +if [ "${NO_ADDRESS_RESULT}" != "${BAD_MAC_RESULT}" ] +then + exit_fail "/token reveals that no address was provided:" \ + "'${NO_ADDRESS_RESULT}' vs '${BAD_MAC_RESULT}'" +fi +if [ "${UNKNOWN_RESULT}" != "${BAD_MAC_RESULT}" ] +then + exit_fail "/token reveals that the validation is unknown:" \ + "'${UNKNOWN_RESULT}' vs '${BAD_MAC_RESULT}'" +fi +echo " OK" + +# The genuine code must of course still work; the solved validation above +# was never consumed, since all we sent for it was a forgery. +echo -n "Redeeming the genuine code..." +post_token "${TCODE}" +case "${TOKEN_RESULT}" in + 200\ *) + # Good! + ;; + *) + exit_fail "Expected 200 for the genuine code, got: ${TOKEN_RESULT}" + ;; +esac +echo " OK" + +exit 0 diff --git a/src/challengerdb/challenger-0005.sql b/src/challengerdb/challenger-0005.sql @@ -0,0 +1,34 @@ +-- +-- This file is part of Challenger +-- Copyright (C) 2026 Taler Systems SA +-- +-- Challenger is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- Challenger is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- Challenger; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +-- Everything in one big transaction +BEGIN; + +-- Check patch versioning is in place. +SELECT _v.register_patch('challenger-0005', NULL, NULL); + +SET search_path TO challenger; + +-- A PIN that was generated for transmission but whose delivery has not +-- been confirmed yet. It only becomes 'last_pin' once the AUTH_COMMAND +-- helper exited successfully, so that a failed transmission does not +-- invalidate the PIN the user may already hold. +ALTER TABLE validations + ADD COLUMN pending_pin INT4 DEFAULT NULL; +COMMENT ON COLUMN validations.pending_pin + IS 'PIN that was passed to the AUTH_COMMAND helper but whose transmission was not confirmed yet; promoted to last_pin when the helper terminates successfully'; + +COMMIT; diff --git a/src/challengerdb/do_challenge_address.c b/src/challengerdb/do_challenge_address.c @@ -120,3 +120,39 @@ CHALLENGERDB_do_challenge_address ( return GNUNET_DB_STATUS_SUCCESS_NO_RESULTS; return qs; } + + +enum GNUNET_DB_QueryStatus +CHALLENGERDB_do_challenge_address_confirm_pin ( + struct CHALLENGERDB_PostgresContext *ctx, + const struct CHALLENGER_ValidationNonceP *nonce, + uint32_t *auth_attempts_left) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (nonce), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint32 ("auth_attempts_left", + auth_attempts_left), + GNUNET_PQ_result_spec_end + }; + + PREPARE (ctx, + "do_challenge_address_confirm_pin", + "UPDATE validations SET" + " last_pin=pending_pin" + " ,pending_pin=NULL" + " ,auth_attempts_left=3" + " WHERE nonce=$1" + /* nothing to promote if we did not just transmit a PIN */ + " AND pending_pin IS NOT NULL" + /* never resurrect an already solved validation */ + " AND auth_attempts_left >= 0" + " RETURNING auth_attempts_left;"); + return GNUNET_PQ_eval_prepared_singleton_select ( + ctx->conn, + "do_challenge_address_confirm_pin", + params, + rs); +} diff --git a/src/challengerdb/do_challenge_address.sql b/src/challengerdb/do_challenge_address.sql @@ -50,6 +50,7 @@ SELECT address ,last_tx_time ,client_redirect_uri ,last_pin + ,pending_pin ,auth_attempts_left ,client_state INTO my_status @@ -130,17 +131,31 @@ THEN my_status.address = in_address::JSONB::TEXT; my_status.pin_transmissions_left = 3; my_status.last_tx_time = 0; + -- The PIN generated below is now merely 'pending' until the helper + -- confirms the transmission, so -- unlike before this patch -- this call + -- no longer necessarily overwrites 'last_pin'. The PIN on file went to + -- the *previous* address, so it must be dropped here: if the helper then + -- fails, the user must be left without a usable PIN rather than with one + -- that attests an address it was never sent to. + my_status.last_pin = NULL; + my_status.pending_pin = NULL; + my_status.auth_attempts_left = 0; + out_last_pin = 0; + out_auth_attempts_left = 0; my_do_update=TRUE; END IF; IF ( (my_status.pin_transmissions_left > 0) AND (my_status.last_tx_time <= in_retransmit_cutoff) ) THEN - -- enough time has passed since the last transmission, so - -- we are changing the PIN, update counters + -- Enough time has passed since the last transmission, so we are changing + -- the PIN, update counters. The new PIN is only stored as 'pending_pin': + -- it is promoted to 'last_pin' (and 'auth_attempts_left' reset) by + -- CHALLENGERDB_do_challenge_address_confirm_pin() once the AUTH_COMMAND + -- helper confirmed the transmission. Until then the PIN the user may + -- already hold from an earlier transmission stays valid. my_status.pin_transmissions_left = my_status.pin_transmissions_left - 1; - my_status.last_pin = in_tan; - my_status.auth_attempts_left = 3; + my_status.pending_pin = in_tan; my_status.last_tx_time = in_now; out_auth_attempts_left = 3; out_pin_transmit=TRUE; @@ -157,6 +172,7 @@ THEN ,pin_transmissions_left=my_status.pin_transmissions_left ,last_tx_time=my_status.last_tx_time ,last_pin=my_status.last_pin + ,pending_pin=my_status.pending_pin ,auth_attempts_left=my_status.auth_attempts_left WHERE nonce=in_nonce; END IF; diff --git a/src/challengerdb/meson.build b/src/challengerdb/meson.build @@ -20,6 +20,7 @@ generated_sql = [ ['challenger-0002.sql', ['challenger-0002.sql']], ['challenger-0003.sql', ['challenger-0003.sql']], ['challenger-0004.sql', ['challenger-0004.sql']], + ['challenger-0005.sql', ['challenger-0005.sql']], ] foreach g : generated_sql diff --git a/src/challengerdb/test_challenger_db.c b/src/challengerdb/test_challenger_db.c @@ -149,6 +149,16 @@ challenge_validation (struct CHALLENGER_ValidationNonceP *nonce, &address_refused, &solved); json_decref (address); + /* The PIN is only pending until its transmission is confirmed; this + stands in for a successful AUTH_COMMAND run. */ + qs = CHALLENGERDB_do_challenge_address_confirm_pin (pg, + nonce, + &auth_attempts_left); + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } GNUNET_free (state); GNUNET_free (redirect_uri); if ( (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) || diff --git a/src/include/challenger-database/do_challenge_address.h b/src/include/challenger-database/do_challenge_address.h @@ -33,6 +33,11 @@ * address did not change, the operation is successful even without * the counter change. * + * Note that a newly generated PIN is only stored as *pending*: it does not + * become the PIN we accept until + * #CHALLENGERDB_do_challenge_address_confirm_pin() is called, so that a + * failed transmission does not invalidate a PIN the user already holds. + * * @param cls * @param nonce unique nonce to use to identify the validation * @param address the new address to validate @@ -68,4 +73,30 @@ CHALLENGERDB_do_challenge_address ( bool *address_refused, bool *solved); + +/** + * Confirm that the PIN generated by the last + * #CHALLENGERDB_do_challenge_address() call was actually transmitted to the + * address, and thus make it the PIN we accept from the user. Also resets + * the number of authentication attempts the user has on the new PIN. + * + * Must only be called once the transmission helper terminated successfully; + * if it is never called, the previous PIN (if any) remains valid. + * + * @param ctx database context to use + * @param nonce unique nonce identifying the validation + * @param[out] auth_attempts_left set to the number of attempts the user has + * on the now-current PIN + * @return transaction status: + * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the PIN was promoted + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if there was no pending PIN, or the + * validation is unknown or already solved + * #GNUNET_DB_STATUS_HARD_ERROR on failure + */ +enum GNUNET_DB_QueryStatus +CHALLENGERDB_do_challenge_address_confirm_pin ( + struct CHALLENGERDB_PostgresContext *ctx, + const struct CHALLENGER_ValidationNonceP *nonce, + uint32_t *auth_attempts_left); + #endif