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:
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