challenger

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

commit b07ddd6d1ec13de17bd4c51b2836c064b4ff920c
parent d1aae4b76347b31a72b048fcf31ff82d8012f9ca
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 09:48:07 +0200

hardening: only create tokens for solved challenges

Diffstat:
Msrc/challengerdb/do_insert_token.c | 9++++++---
Msrc/challengerdb/get_validation_pkce.c | 7++++++-
Msrc/challengerdb/meson.build | 1+
Msrc/challengerdb/test_challenger_db.c | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/challenger-database/do_insert_token.h | 13+++++++++++--
Msrc/include/challenger-database/get_validation_pkce.h | 15++++++++++-----
6 files changed, 314 insertions(+), 11 deletions(-)

diff --git a/src/challengerdb/do_insert_token.c b/src/challengerdb/do_insert_token.c @@ -47,15 +47,18 @@ CHALLENGERDB_do_insert_token (struct CHALLENGERDB_PostgresContext *ctx, validation by deleting it as we mint the token, so the same authorization code cannot be replayed to mint additional tokens. A replay finds no matching validations row and thus inserts no token (NO_RESULTS), which the - caller maps to 'invalid_grant'. The 'address IS NOT NULL' guard avoids - violating the tokens.address NOT NULL constraint (and only consumes a - validation that actually carries a solved address). */ + caller maps to 'invalid_grant'. The 'auth_attempts_left < 0' guard is what + restricts us to validations that were actually *solved*: -1 is the sentinel + challenger_do_validate_and_solve_pin() writes once the user entered the + correct PIN. The 'address IS NOT NULL' guard additionally avoids violating + the tokens.address NOT NULL constraint. */ PREPARE (ctx, "do_insert_token", "WITH consumed AS (" " DELETE FROM validations" " WHERE nonce=$1" " AND address IS NOT NULL" + " AND auth_attempts_left < 0" " RETURNING address, last_tx_time" ") INSERT INTO tokens" " (access_token" diff --git a/src/challengerdb/get_validation_pkce.c b/src/challengerdb/get_validation_pkce.c @@ -91,7 +91,12 @@ CHALLENGERDB_get_validation_pkce ( " USING (client_serial_id)" " WHERE nonce=$1" " AND expiration_time > $2" - " AND client_serial_id=$3"); + " AND client_serial_id=$3" + /* -1 is the sentinel challenger_do_validate_and_solve_pin() writes + once the correct PIN was entered; without this the /token endpoint + would happily mint a token for a validation where the user never + proved anything. Must match the guard in do_insert_token.c. */ + " AND auth_attempts_left < 0"); return GNUNET_PQ_eval_prepared_singleton_select (ctx->conn, "get_validation_pkce", params, diff --git a/src/challengerdb/meson.build b/src/challengerdb/meson.build @@ -108,6 +108,7 @@ test_challenger_db_postgres = executable( libchallengerdb_dep, gnunetpq_dep, talerutil_dep, + json_dep, ], include_directories: [incdir, configuration_inc], build_by_default: false, diff --git a/src/challengerdb/test_challenger_db.c b/src/challengerdb/test_challenger_db.c @@ -26,6 +26,12 @@ #include "challenger-database/create_tables.h" #include "challenger-database/preflight.h" #include "challenger-database/gc.h" +#include "challenger-database/insert_client.h" +#include "challenger-database/do_insert_validation.h" +#include "challenger-database/do_challenge_address.h" +#include "challenger-database/do_solve_challenge.h" +#include "challenger-database/do_insert_token.h" +#include "challenger-database/get_validation_pkce.h" #include "challenger_util.h" @@ -37,6 +43,16 @@ } while (0) /** + * Secret of the client the tests register and act as. + */ +#define CLIENT_SECRET "secret-token:test-secret" + +/** + * Redirect URI of the client the tests register. + */ +#define CLIENT_URI "http://client.example.com/" + +/** * Global return value for the test. Initially -1, set to 0 upon * completion. Other values indicate some kind of error. */ @@ -47,6 +63,264 @@ static int result; */ static struct CHALLENGERDB_PostgresContext *pg; +/** + * Serial ID of the client registered by #setup_client(). + */ +static uint64_t client_id; + + +/** + * Register the OAuth client all tests act as. + * + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +setup_client (void) +{ + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + CHALLENGERDB_insert_client (pg, + CLIENT_URI, + CLIENT_SECRET, + &client_id)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Create a fresh validation for #client_id and have a PIN transmitted + * for it, leaving the validation in the state a user reaches by posting + * an address to ``/challenge`` and then walking away. + * + * @param[out] nonce set to the nonce identifying the new validation + * @param[out] pin set to the PIN that was "transmitted" + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +challenge_validation (struct CHALLENGER_ValidationNonceP *nonce, + uint32_t *pin) +{ + struct GNUNET_TIME_Absolute expiration + = GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS); + json_t *address; + char *state = NULL; + char *redirect_uri = NULL; + struct GNUNET_TIME_Absolute last_tx_time; + uint32_t auth_attempts_left; + bool pin_transmit; + bool address_refused; + bool solved; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_CRYPTO_random_block (nonce, + sizeof (*nonce)); + qs = CHALLENGERDB_do_insert_validation (pg, + client_id, + CLIENT_SECRET, + nonce, + expiration, + NULL); + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + address = json_pack ("{s:s}", + "filename", + "test-challenger-db.txt"); + GNUNET_assert (NULL != address); + *pin = 424242; + qs = CHALLENGERDB_do_challenge_address (pg, + nonce, + address, + GNUNET_TIME_UNIT_ZERO, + pin, + &state, + &last_tx_time, + &auth_attempts_left, + &pin_transmit, + &redirect_uri, + &address_refused, + &solved); + json_decref (address); + GNUNET_free (state); + GNUNET_free (redirect_uri); + if ( (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) || + (! pin_transmit) || + address_refused || + solved) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Enter @a pin for the validation under @a nonce and check it was accepted. + * + * @param nonce validation to solve + * @param pin PIN to enter + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +solve_validation (const struct CHALLENGER_ValidationNonceP *nonce, + uint32_t pin) +{ + char *state = NULL; + char *redirect_uri = NULL; + uint32_t addr_left; + uint32_t auth_attempts_left; + uint32_t pin_transmissions_left; + bool solved; + bool exhausted; + bool no_challenge; + enum GNUNET_DB_QueryStatus qs; + + qs = CHALLENGERDB_do_solve_challenge (pg, + nonce, + pin, + &solved, + &exhausted, + &no_challenge, + &state, + &addr_left, + &auth_attempts_left, + &pin_transmissions_left, + &redirect_uri); + GNUNET_free (state); + GNUNET_free (redirect_uri); + if ( (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) || + (! solved) || + exhausted || + no_challenge) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Mint an access token for the validation under @a nonce. + * + * @param nonce validation to redeem + * @param[out] token set to the (random) access token we tried to store + * @return transaction status of CHALLENGERDB_do_insert_token() + */ +static enum GNUNET_DB_QueryStatus +insert_token (const struct CHALLENGER_ValidationNonceP *nonce, + struct CHALLENGER_AccessTokenP *token) +{ + GNUNET_CRYPTO_random_block (token, + sizeof (*token)); + return CHALLENGERDB_do_insert_token (pg, + nonce, + token, + GNUNET_TIME_UNIT_HOURS, + GNUNET_TIME_UNIT_DAYS); +} + + +/** + * Test that a validation for which the user never entered the correct + * PIN cannot be redeemed for an access token, and is not even visible to + * the ``/token`` lookup. Without the ``auth_attempts_left < 0`` guard + * anyone able to compute the authorization code could mint an + * attestation for an address nobody ever proved control over. + * + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +test_unsolved_not_redeemable (void) +{ + struct CHALLENGER_ValidationNonceP nonce; + struct CHALLENGER_AccessTokenP token; + uint32_t pin; + + if (GNUNET_OK != + challenge_validation (&nonce, + &pin)) + return GNUNET_SYSERR; + { + char *client_secret; + json_t *address; + char *client_scope; + char *client_state; + char *client_redirect_uri; + char *code_challenge; + uint32_t code_challenge_method; + + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + CHALLENGERDB_get_validation_pkce (pg, + &nonce, + client_id, + &client_secret, + &address, + &client_scope, + &client_state, + &client_redirect_uri, + &code_challenge, + &code_challenge_method)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + } + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + insert_token (&nonce, + &token)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Test that a solved validation is redeemable, and redeemable exactly + * once (the authorization code must not be replayable). + * + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +test_solved_redeemable (void) +{ + struct CHALLENGER_ValidationNonceP nonce; + struct CHALLENGER_AccessTokenP token; + uint32_t pin; + + if ( (GNUNET_OK != + challenge_validation (&nonce, + &pin)) || + (GNUNET_OK != + solve_validation (&nonce, + pin)) ) + return GNUNET_SYSERR; + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + insert_token (&nonce, + &token)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + /* replay: the validation was consumed, so this must not mint a token */ + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + insert_token (&nonce, + &token)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + /** * Main function that will be run by the scheduler. @@ -89,6 +363,12 @@ run (void *cls) CHALLENGERDB_gc (pg, ts)); } + FAILIF (GNUNET_OK != + setup_client ()); + FAILIF (GNUNET_OK != + test_unsolved_not_redeemable ()); + FAILIF (GNUNET_OK != + test_solved_redeemable ()); result = 0; drop: GNUNET_break (GNUNET_OK == diff --git a/src/include/challenger-database/do_insert_token.h b/src/include/challenger-database/do_insert_token.h @@ -28,14 +28,23 @@ /** - * Add access @a grant to address under @a nonce. + * Add access @a grant to address under @a nonce, consuming the validation in + * the same statement so that the authorization code cannot be replayed. + * + * Only *solved* validations that carry an address are consumed; anything else + * yields #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS, which the caller maps to the + * OAuth 2.0 ``invalid_grant`` error. * * @param cls closure * @param nonce validation process to grant access to * @param grant grant token that grants access * @param grant_expiration for how long should the grant be valid * @param address_expiration for how long after validation do we consider addresses to be valid - * @return transaction status + * @return transaction status: + * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the token was minted + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if there is no solved validation + * under @a nonce (unknown, unsolved or already redeemed) + * #GNUNET_DB_STATUS_HARD_ERROR on failure */ enum GNUNET_DB_QueryStatus CHALLENGERDB_do_insert_token ( diff --git a/src/include/challenger-database/get_validation_pkce.h b/src/include/challenger-database/get_validation_pkce.h @@ -29,9 +29,13 @@ /** - * Return validation details. Used by ``/solve``, ``/auth`` and - * ``/info`` endpoints to authorize and return validated user - * address to the client. + * Return validation details. Used by the ``/token`` endpoint to check the + * authorization code and the PKCE binding before minting an access token. + * + * Only returns validations that were actually *solved* (the user entered the + * correct PIN); an unsolved, expired or foreign validation is reported as + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS. The predicate must stay in sync with + * the one in CHALLENGERDB_do_insert_token(). * * @param cls * @param nonce unique nonce to use to identify the validation @@ -44,8 +48,9 @@ * @param[out] code_challenge set to PKCE code challenge * @param[out] code_challenge_method set to PKCE code challenge method enum * @return transaction status: - * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the nonce was found - * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if we do not know the nonce + * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the nonce was found and solved + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if we do not know the nonce, it + * expired, it belongs to another client or it was never solved * #GNUNET_DB_STATUS_HARD_ERROR on failure */ enum GNUNET_DB_QueryStatus