anastasis

Credential backup and recovery protocol and service
Log | Files | Refs | Submodules | README | LICENSE

commit 60c835dd032c1ac0e2bf43550c370c066783b7dd
parent 3db952e4cd61865175e0905caf72ba27e585814a
Author: Christian Grothoff <christian@grothoff.org>
Date:   Fri, 31 Jul 2026 15:48:24 +0200

improve challenge retry limits

Diffstat:
MNEWS | 9---------
Mconfigure | 9+++++++++
Mmeson.build | 16+++++++++++-----
Msrc/backend/anastasis-httpd_truth-solve.c | 11+++++++++++
Msrc/include/anastasis/anastasis-database/common.h | 6++++++
Msrc/include/anastasis/anastasis-database/do_verify_challenge_code.h | 6+++++-
Msrc/stasis/do_verify_challenge_code.c | 37++++++++++++++++++++++++++++++++++---
Msrc/stasis/test_anastasis_db.c | 27+++++++++++++++++++++++++++
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;