commit 60c835dd032c1ac0e2bf43550c370c066783b7dd
parent 3db952e4cd61865175e0905caf72ba27e585814a
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 31 Jul 2026 15:48:24 +0200
improve challenge retry limits
Diffstat:
8 files changed, 103 insertions(+), 18 deletions(-)
diff --git a/NEWS b/NEWS
@@ -21,12 +21,3 @@ Noteworthy changes 0.8.0
up the stasis-0002 migration; it replaces the challenge-code index with
one garbage collection can actually use.
-* The build is hardened by default (PIE, RELRO/BIND_NOW, _FORTIFY_SOURCE,
- stack protector); use ./configure --disable-hardening for profiling or
- debugging.
-
-* The "file" authorization method, which is meant for testing, used the
- client-supplied truth directly as the path it wrote the challenge code to.
- It now only writes inside the directory configured as
- "[authorization-file] DIRECTORY".
-
diff --git a/configure b/configure
@@ -248,10 +248,19 @@ for dr in $standard_dirs; do
fi
done
+# The default buildtype (set in meson.build) is debugoptimized, so that
+# _FORTIFY_SOURCE is in effect; optimisation makes gcov line attribution
+# unreliable, so a coverage build asks for an unoptimised one instead.
+mesonbuildtype=""
+if [ "$enable_coverage" = "true" ]; then
+ mesonbuildtype="-Dbuildtype=debug"
+fi
+
${MESON} setup \
-Ddefault_library=shared \
$mesondiropts \
$mesonfeatopts \
+ $mesonbuildtype \
${var_mesonbuilddir:-build} \
${var_srcdir} || exit 1
diff --git a/meson.build b/meson.build
@@ -6,7 +6,11 @@ project(
version: run_command('sh', 'scripts/get_version.sh', check: true).stdout().strip(),
# PIE is a built-in option, so it has to be set here rather than under the
# 'hardening' option below; -Db_pie=false still turns it off.
- default_options: ['b_pie=true'],
+ # 'debugoptimized' (-O2 -g) rather than Meson's 'debug' default: without
+ # optimisation _FORTIFY_SOURCE does nothing, so a plain developer build
+ # would never exercise the fortified code paths that releases ship with.
+ # -Dbuildtype=debug still gives an unoptimised build.
+ default_options: ['b_pie=true', 'buildtype=debugoptimized'],
)
cc = meson.get_compiler('c')
@@ -67,10 +71,12 @@ if not get_option('only-doc')
'-fstack-clash-protection',
)
# _FORTIFY_SOURCE needs optimisation to do anything and warns without
- # it, so only set it when the build is actually optimised. 'plain' is
- # excluded as well: it means the packager supplies the flags, and
- # distributions that pass their own -D_FORTIFY_SOURCE would then get a
- # "redefined" warning in every translation unit.
+ # it, so only set it when the build is actually optimised -- which the
+ # default buildtype above ensures, so this skips only for an explicit
+ # -Dbuildtype=debug. 'plain' is excluded as well: it means the
+ # packager supplies the flags, and distributions that pass their own
+ # -D_FORTIFY_SOURCE would then get a "redefined" warning in every
+ # translation unit.
if get_option('optimization') not in ['0', 'g', 'plain']
# The obvious test, '#if _FORTIFY_SOURCE < 3', can never fail: it
# reads back the value just put on the command line. glibc does
diff --git a/src/backend/anastasis-httpd_truth-solve.c b/src/backend/anastasis-httpd_truth-solve.c
@@ -927,6 +927,12 @@ rate_limit (struct SolveContext *gc)
case ANASTASIS_DB_CODE_STATUS_CHALLENGE_CODE_MISMATCH:
/* good, what we wanted */
return GNUNET_OK;
+ case ANASTASIS_DB_CODE_STATUS_RATE_LIMITED:
+ /* the code we just obtained was already out of attempts */
+ return (MHD_YES ==
+ reply_rate_limited (gc))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
case ANASTASIS_DB_CODE_STATUS_HARD_ERROR:
case ANASTASIS_DB_CODE_STATUS_SOFT_ERROR:
GNUNET_break (0);
@@ -1452,6 +1458,11 @@ AH_handler_truth_solve (
MHD_HTTP_FORBIDDEN,
TALER_EC_ANASTASIS_TRUTH_CHALLENGE_FAILED,
NULL);
+ case ANASTASIS_DB_CODE_STATUS_RATE_LIMITED:
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Challenge is out of attempts, client must await a new code\n");
+ GNUNET_free (decrypted_truth);
+ return reply_rate_limited (gc);
case ANASTASIS_DB_CODE_STATUS_HARD_ERROR:
case ANASTASIS_DB_CODE_STATUS_SOFT_ERROR:
GNUNET_break (0);
diff --git a/src/include/anastasis/anastasis-database/common.h b/src/include/anastasis/anastasis-database/common.h
@@ -35,6 +35,12 @@
enum ANASTASIS_DB_CodeStatus
{
/**
+ * The challenge code that is currently answerable has no attempts
+ * left; the client must wait for the next code to be issued.
+ */
+ ANASTASIS_DB_CODE_STATUS_RATE_LIMITED = -4,
+
+ /**
* Provided authentication code does not match database content.
*/
ANASTASIS_DB_CODE_STATUS_CHALLENGE_CODE_MISMATCH = -3,
diff --git a/src/include/anastasis/anastasis-database/do_verify_challenge_code.h b/src/include/anastasis/anastasis-database/do_verify_challenge_code.h
@@ -28,8 +28,12 @@
/**
* Verify the provided code with the code on the server.
+ * Only the newest unexpired code issued for @a truth_uuid is answerable.
* If the code matches the function will return with success, if the code
- * does not match, the retry counter will be decreased by one.
+ * does not match, the retry counter will be decreased by one. Once that
+ * counter reaches zero the code is no longer answerable and
+ * #ANASTASIS_DB_CODE_STATUS_RATE_LIMITED is returned until the next code
+ * is issued.
*
* @param truth_uuid identification of the challenge which the code corresponds to
* @param hashed_code code which the user provided and wants to verify
diff --git a/src/stasis/do_verify_challenge_code.c b/src/stasis/do_verify_challenge_code.c
@@ -54,6 +54,11 @@ struct CheckValidityContext
bool satisfied;
/**
+ * Set to true if the answerable code is out of attempts.
+ */
+ bool exhausted;
+
+ /**
* Set to true if we had a database failure.
*/
bool db_failure;
@@ -80,10 +85,13 @@ check_valid_code (void *cls,
for (unsigned int i = 0; i < num_results; i++)
{
uint64_t server_code;
+ uint32_t retry_counter;
uint8_t sat;
struct GNUNET_PQ_ResultSpec rs[] = {
GNUNET_PQ_result_spec_uint64 ("code",
&server_code),
+ GNUNET_PQ_result_spec_uint32 ("retry_counter",
+ &retry_counter),
GNUNET_PQ_result_spec_auto_from_type ("satisfied",
&sat),
GNUNET_PQ_result_spec_end
@@ -102,6 +110,18 @@ check_valid_code (void *cls,
"Found issued challenge %llu (client: %s)\n",
(unsigned long long) server_code,
GNUNET_h2s (cvc->hashed_code));
+ if (0 == retry_counter)
+ {
+ /* The answerable code is out of attempts. Do not fall back to an
+ older sibling that still has attempts left: that would make the
+ effective budget 3 times the number of live codes. Do not answer
+ it either, not even correctly -- the counter is the rate limit. */
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Challenge %llu is out of attempts, rate limiting\n",
+ (unsigned long long) server_code);
+ cvc->exhausted = true;
+ return;
+ }
{
struct GNUNET_HashCode shashed_code;
@@ -144,8 +164,12 @@ check_valid_code (void *cls,
/**
* Verify the provided code with the code on the server.
+ * Only the newest unexpired code issued for @a truth_uuid is answerable.
* If the code matches the function will return with success, if the code
- * does not match, the retry counter will be decreased by one.
+ * does not match, the retry counter will be decreased by one. Once that
+ * counter reaches zero the code is no longer answerable and
+ * #ANASTASIS_DB_CODE_STATUS_RATE_LIMITED is returned until the next code
+ * is issued.
*
* @param truth_uuid identification of the challenge which the code corresponds to
* @param hashed_code code which the user provided and wants to verify
@@ -183,15 +207,20 @@ ANASTASIS_DB_do_verify_challenge_code (
codes at once, and without the LIMIT every one of them that does not
match burns a retry -- so a single wrong guess could exhaust the counter,
and even a correct answer cost retries for its siblings. This matches
- what insert_challenge_code.c already assumes when it issues a code. */
+ what insert_challenge_code.c already assumes when it issues a code.
+ Note that exhausted codes are deliberately *not* filtered out here:
+ doing so would fall through to the newest sibling that still has
+ attempts left, which is exactly the multiplication of the retry budget
+ that the LIMIT is there to prevent. The caller instead learns that the
+ client is rate limited until the next code is issued. */
PREPARE ("do_verify_challenge_code_select",
"SELECT "
" code"
+ ",retry_counter"
",satisfied"
" FROM anastasis_challengecode"
" WHERE truth_uuid=$1"
" AND expiration_date > $2"
- " AND retry_counter != 0"
" ORDER BY creation_date DESC"
" LIMIT 1;");
qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn,
@@ -202,6 +231,8 @@ ANASTASIS_DB_do_verify_challenge_code (
if ( (qs < 0) ||
(cvc.db_failure) )
return ANASTASIS_DB_CODE_STATUS_HARD_ERROR;
+ if (cvc.exhausted)
+ return ANASTASIS_DB_CODE_STATUS_RATE_LIMITED;
*code = cvc.code;
if (cvc.valid)
{
diff --git a/src/stasis/test_anastasis_db.c b/src/stasis/test_anastasis_db.c
@@ -336,6 +336,33 @@ run (void *cls)
&c_hash,
&r_code,
&sat));
+
+ /* Burn the two remaining attempts of the (only) answerable code; the
+ correct answer must then be rejected as well, until a new code is
+ issued. */
+ ANASTASIS_hash_answer (challenge_code + 1,
+ &c_hash);
+ for (unsigned int i = 0; i<2; i++)
+ FAILIF (ANASTASIS_DB_CODE_STATUS_CHALLENGE_CODE_MISMATCH !=
+ ANASTASIS_DB_do_verify_challenge_code (
+ &truth_uuid,
+ &c_hash,
+ &r_code,
+ &sat));
+ FAILIF (ANASTASIS_DB_CODE_STATUS_RATE_LIMITED !=
+ ANASTASIS_DB_do_verify_challenge_code (
+ &truth_uuid,
+ &c_hash,
+ &r_code,
+ &sat));
+ ANASTASIS_hash_answer (challenge_code,
+ &c_hash);
+ FAILIF (ANASTASIS_DB_CODE_STATUS_RATE_LIMITED !=
+ ANASTASIS_DB_do_verify_challenge_code (
+ &truth_uuid,
+ &c_hash,
+ &r_code,
+ &sat));
}
if (-1 == result)
result = 0;