challenger

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

commit 893fbfee7b0f9ef770a7b784360ad1e69e6d5f19
parent 46d3e49ff9b9784e5e95875a7db1e39a5255a9cb
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 15:05:28 +0200

check client_ID again during insert_token transaction to avoid super-hypothetical issues with concurrent transactions interfering

Diffstat:
Msrc/challenger/challenger-httpd_token.c | 10++++++++++
Msrc/challengerdb/do_insert_token.c | 12+++++++++++-
Msrc/challengerdb/test_challenger_db.c | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/include/challenger-database/do_insert_token.h | 11+++++++----
4 files changed, 131 insertions(+), 10 deletions(-)

diff --git a/src/challenger/challenger-httpd_token.c b/src/challenger/challenger-httpd_token.c @@ -714,13 +714,23 @@ CH_handler_token (struct CH_HandlerContext *hc, { struct CHALLENGER_AccessTokenP token; enum GNUNET_DB_QueryStatus qs; + unsigned long long token_client_id; + char token_dummy; + /* bc->client_id was already validated as a number in the client-auth + block above; re-parse it here (it is out of scope) so the redemption + is restricted to validations of the authenticated client. */ + GNUNET_assert (1 == sscanf (bc->client_id, + "%llu%c", + &token_client_id, + &token_dummy)); GNUNET_CRYPTO_random_block (&token, sizeof (token)); for (unsigned int r = 0; r < MAX_RETRIES; r++) { qs = CHALLENGERDB_do_insert_token (CH_context, &bc->nonce, + (uint64_t) token_client_id, &token, CH_token_expiration, CH_validation_expiration); diff --git a/src/challengerdb/do_insert_token.c b/src/challengerdb/do_insert_token.c @@ -29,10 +29,13 @@ enum GNUNET_DB_QueryStatus CHALLENGERDB_do_insert_token (struct CHALLENGERDB_PostgresContext *ctx, const struct CHALLENGER_ValidationNonceP *nonce, + uint64_t client_id, const struct CHALLENGER_AccessTokenP *token, struct GNUNET_TIME_Relative token_expiration, struct GNUNET_TIME_Relative address_expiration) { + struct GNUNET_TIME_Absolute now + = GNUNET_TIME_absolute_get (); struct GNUNET_TIME_Absolute ge = GNUNET_TIME_relative_to_absolute (token_expiration); struct GNUNET_TIME_Absolute ae @@ -42,6 +45,8 @@ CHALLENGERDB_do_insert_token (struct CHALLENGERDB_PostgresContext *ctx, GNUNET_PQ_query_param_auto_from_type (token), GNUNET_PQ_query_param_absolute_time (&ge), GNUNET_PQ_query_param_absolute_time (&ae), + GNUNET_PQ_query_param_absolute_time (&now), + GNUNET_PQ_query_param_uint64 (&client_id), GNUNET_PQ_query_param_end }; @@ -53,7 +58,10 @@ CHALLENGERDB_do_insert_token (struct CHALLENGERDB_PostgresContext *ctx, 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. */ + the tokens.address NOT NULL constraint. Expiration and client are checked + here as well and not merely by the get_validation_pkce() that preceded us: + the two run in separate transactions, so anything we do not repeat is a + TOCTOU window. */ PREPARE (ctx, "do_insert_token", "WITH consumed AS (" @@ -61,6 +69,8 @@ CHALLENGERDB_do_insert_token (struct CHALLENGERDB_PostgresContext *ctx, " WHERE nonce=$1" " AND address IS NOT NULL" " AND auth_attempts_left < 0" + " AND expiration_time > $5" + " AND client_serial_id=$6" " RETURNING address" ") INSERT INTO tokens" " (access_token" diff --git a/src/challengerdb/test_challenger_db.c b/src/challengerdb/test_challenger_db.c @@ -224,18 +224,21 @@ solve_validation (const struct CHALLENGER_ValidationNonceP *nonce, /** * Mint an access token for the validation under @a nonce. * + * @param client client redeeming the authorization code * @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, +insert_token (uint64_t client, + const struct CHALLENGER_ValidationNonceP *nonce, struct CHALLENGER_AccessTokenP *token) { GNUNET_CRYPTO_random_block (token, sizeof (*token)); return CHALLENGERDB_do_insert_token (pg, nonce, + client, token, GNUNET_TIME_UNIT_HOURS, GNUNET_TIME_UNIT_DAYS); @@ -288,7 +291,8 @@ test_unsolved_not_redeemable (void) } } if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != - insert_token (&nonce, + insert_token (client_id, + &nonce, &token)) { GNUNET_break (0); @@ -319,7 +323,8 @@ test_solved_redeemable (void) pin)) ) return GNUNET_SYSERR; if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != - insert_token (&nonce, + insert_token (client_id, + &nonce, &token)) { GNUNET_break (0); @@ -327,7 +332,8 @@ test_solved_redeemable (void) } /* replay: the validation was consumed, so this must not mint a token */ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != - insert_token (&nonce, + insert_token (client_id, + &nonce, &token)) { GNUNET_break (0); @@ -380,7 +386,8 @@ test_address_expiry_is_in_the_future (void) } now = GNUNET_TIME_absolute_get (); if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != - insert_token (&nonce, + insert_token (client_id, + &nonce, &token)) { GNUNET_break (0); @@ -408,6 +415,93 @@ test_address_expiry_is_in_the_future (void) /** + * Test that an expired validation is not consumable, even though the + * caller (``/token``) checked the expiration in an earlier, separate + * transaction. Everything get_validation_pkce() filters on has to be + * repeated by do_insert_token(), or the gap between the two statements is + * an unguarded TOCTOU window. + * + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +test_expired_not_redeemable (void) +{ + struct GNUNET_PQ_ExecuteStatement es[] = { + GNUNET_PQ_make_execute ("UPDATE validations SET expiration_time=1;"), + GNUNET_PQ_EXECUTE_STATEMENT_END + }; + 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_OK != + GNUNET_PQ_exec_statements (pg->conn, + es)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + insert_token (client_id, + &nonce, + &token)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Test that a validation cannot be consumed by a client it does not + * belong to. + * + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +test_foreign_client_not_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_NO_RESULTS != + insert_token (client_id + 1, + &nonce, + &token)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + /* ... and the rightful client still can */ + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + insert_token (client_id, + &nonce, + &token)) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** * Main function that will be run by the scheduler. * * @param cls closure with config @@ -456,6 +550,10 @@ run (void *cls) test_solved_redeemable ()); FAILIF (GNUNET_OK != test_address_expiry_is_in_the_future ()); + FAILIF (GNUNET_OK != + test_expired_not_redeemable ()); + FAILIF (GNUNET_OK != + test_foreign_client_not_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 @@ -31,12 +31,13 @@ * 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. + * Only *solved*, unexpired validations of @a client_id 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 client_id serial id of the authenticated client redeeming the code * @param grant grant token that grants access * @param grant_expiration for how long should the grant be valid * @param address_expiration for how long from now do we consider the address @@ -44,13 +45,15 @@ * @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) + * under @a nonce (unknown, unsolved, expired, of another client or + * already redeemed) * #GNUNET_DB_STATUS_HARD_ERROR on failure */ enum GNUNET_DB_QueryStatus CHALLENGERDB_do_insert_token ( struct CHALLENGERDB_PostgresContext *ctx, const struct CHALLENGER_ValidationNonceP *nonce, + uint64_t client_id, const struct CHALLENGER_AccessTokenP *grant, struct GNUNET_TIME_Relative grant_expiration, struct GNUNET_TIME_Relative address_expiration);