anastasis

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

commit baee9a82c8d856d5eeb4567bbc45d621d60eb58f
parent 1bc1d42ea8e3f007614ed6dc491706d6d345e1f8
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed, 29 Jul 2026 19:41:42 +0200

refuse a TOTP code whose time step was already used

Diffstat:
Msrc/authorization/anastasis_authorization_plugin_totp.c | 54+++++++++++++++++++++++++++++++++++++++++++++++++-----
Asrc/include/anastasis/anastasis-database/update_totp_counter.h | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/anastasis_database_lib.h | 1+
Msrc/include/meson.build | 1+
Msrc/stasis/meson.build | 1+
Msrc/stasis/stasis-0002.sql | 10++++++++++
Msrc/stasis/test_anastasis_db.c | 26++++++++++++++++++++++++++
Asrc/stasis/update_totp_counter.c | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 200 insertions(+), 5 deletions(-)

diff --git a/src/authorization/anastasis_authorization_plugin_totp.c b/src/authorization/anastasis_authorization_plugin_totp.c @@ -64,6 +64,12 @@ struct ANASTASIS_AUTHORIZATION_State struct GNUNET_HashCode valid_replies[TIME_INTERVAL_RANGE * 2 + 1]; /** + * Time step counter each entry of @e valid_replies was computed for, + * in the same order. Needed to tell a replay from a fresh code. + */ + uint64_t counters[TIME_INTERVAL_RANGE * 2 + 1]; + + /** * Our context. */ const struct ANASTASIS_AuthorizationContext *ac; @@ -131,12 +137,14 @@ totp_validate (void *cls, * @param time_off offset to apply when computing the code * @param key input key material * @param key_size number of bytes in @a key + * @param[out] counter set to the time step counter the code belongs to * @return TOTP code at this time */ static uint64_t compute_totp (int time_off, const void *key, - size_t key_size) + size_t key_size, + uint64_t *counter) { struct GNUNET_TIME_Absolute now; time_t t; @@ -157,7 +165,8 @@ compute_totp (int time_off, time_off--; } t = now.abs_value_us / GNUNET_TIME_UNIT_SECONDS.rel_value_us; - ctr = GNUNET_htonll (t / 30LLU); + *counter = t / 30LLU; + ctr = GNUNET_htonll (*counter); { gcry_md_hd_t md; @@ -243,7 +252,8 @@ totp_start (void *cls, { want = compute_totp (i, data, - data_length); + data_length, + &as->counters[off]); ANASTASIS_hash_answer (want, &as->valid_replies[off++]); } @@ -271,10 +281,44 @@ totp_solve (struct ANASTASIS_AUTHORIZATION_State *as, const char *lang; for (unsigned int i = 0; i<=TIME_INTERVAL_RANGE * 2; i++) - if (0 == + { + enum GNUNET_DB_QueryStatus qs; + + if (0 != GNUNET_memcmp (challenge_response, &as->valid_replies[i])) - return ANASTASIS_AUTHORIZATION_SRES_FINISHED; + continue; + /* The code is right for time step @e counters[i], but a code stays right + for as long as its window lasts, so anyone who saw it could send it + again. Consume the time step: this succeeds at most once (RFC 6238, + section 5.2), and because the check and the store are the same + statement two requests carrying the same code cannot both win. */ + qs = ANASTASIS_DB_update_totp_counter (&as->truth_uuid, + as->counters[i]); + if (qs < 0) + { + GNUNET_break (0); + if (MHD_NO == + TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "update_totp_counter")) + return ANASTASIS_AUTHORIZATION_SRES_FAILED_REPLY_FAILED; + return ANASTASIS_AUTHORIZATION_SRES_FAILED; + } + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs) + { + /* Replay of a code we already honoured (or the truth is gone by now). + Answer exactly as for a wrong code, so that the client learns + nothing from which of the two it was. */ + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Refusing TOTP code for time step %llu of truth %s: already used\n", + (unsigned long long) as->counters[i], + TALER_B2S (&as->truth_uuid)); + break; + } + return ANASTASIS_AUTHORIZATION_SRES_FINISHED; + } mime = MHD_lookup_connection_value (connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_ACCEPT); diff --git a/src/include/anastasis/anastasis-database/update_totp_counter.h b/src/include/anastasis/anastasis-database/update_totp_counter.h @@ -0,0 +1,50 @@ +/* + This file is part of Anastasis + Copyright (C) 2026 Anastasis SARL + + Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + Anastasis; see the file COPYING.GPL. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file include/anastasis/anastasis-database/update_totp_counter.h + * @brief Anastasis database: accept a TOTP time step counter once + * @author Christian Grothoff + */ +#ifndef ANASTASIS_DATABASE_UPDATE_TOTP_COUNTER_H +#define ANASTASIS_DATABASE_UPDATE_TOTP_COUNTER_H + +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_db_lib.h> +#include "anastasis_service.h" +#include "anastasis/anastasis-database/common.h" + +/** + * Consume the TOTP time step @a counter for @a truth_uuid. + * + * A TOTP code is valid for a whole window of time steps, so it can be + * replayed until that window passes unless the provider remembers which step + * it already honoured (RFC 6238, section 5.2). Succeeds only if @a counter is + * strictly newer than the last counter accepted for this truth, and records it + * in the same statement, so that two requests carrying the same code cannot + * both succeed. + * + * @param truth_uuid identification of the challenge the code belongs to + * @param counter time step counter the code was computed for + * @return #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the counter was accepted, + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if it is a replay or the + * truth does not exist, otherwise the error status + */ +enum GNUNET_DB_QueryStatus +ANASTASIS_DB_update_totp_counter ( + const struct ANASTASIS_CRYPTO_TruthUUIDP *truth_uuid, + uint64_t counter); + +#endif diff --git a/src/include/anastasis_database_lib.h b/src/include/anastasis_database_lib.h @@ -48,6 +48,7 @@ #include "anastasis/anastasis-database/get_truth_payment.h" #include "anastasis/anastasis-database/do_verify_challenge_code.h" #include "anastasis/anastasis-database/update_to_challenge_code_satisfied.h" +#include "anastasis/anastasis-database/update_totp_counter.h" #include "anastasis/anastasis-database/get_exists_challenge_code_satisfied.h" #include "anastasis/anastasis-database/insert_challenge_code.h" #include "anastasis/anastasis-database/update_to_challenge_code_sent.h" diff --git a/src/include/meson.build b/src/include/meson.build @@ -42,6 +42,7 @@ install_data( 'anastasis/anastasis-database/get_truth_payment.h', 'anastasis/anastasis-database/do_verify_challenge_code.h', 'anastasis/anastasis-database/update_to_challenge_code_satisfied.h', + 'anastasis/anastasis-database/update_totp_counter.h', 'anastasis/anastasis-database/get_exists_challenge_code_satisfied.h', 'anastasis/anastasis-database/insert_challenge_code.h', 'anastasis/anastasis-database/update_to_challenge_code_sent.h', diff --git a/src/stasis/meson.build b/src/stasis/meson.build @@ -54,6 +54,7 @@ libanastasisdb_SOURCES = [ 'get_account.c', 'get_pending_challenge_payment.c', 'update_to_challenge_code_satisfied.c', + 'update_totp_counter.c', 'update_to_challenge_code_sent.c', 'insert_auth_iban_in.c', 'insert_challenge_payment.c', diff --git a/src/stasis/stasis-0002.sql b/src/stasis/stasis-0002.sql @@ -31,4 +31,14 @@ CREATE INDEX IF NOT EXISTS anastasis_challengecode_expiration_index COMMENT ON INDEX anastasis_challengecode_expiration_index IS 'for challenge garbage collection'; +-- A TOTP code stays valid for a whole window of time steps, so without +-- remembering which step was already used an observed code can simply be +-- replayed while the window lasts. RFC 6238 section 5.2 requires that a code +-- be accepted at most once; this column is what makes that decidable. It +-- lives on the truth so that it is removed by the same garbage collection. +ALTER TABLE anastasis_truth + ADD COLUMN IF NOT EXISTS last_totp_counter INT8 NOT NULL DEFAULT 0; +COMMENT ON COLUMN anastasis_truth.last_totp_counter + IS 'Highest TOTP time step counter already accepted for this truth; a code for this or an earlier step is a replay and is refused. Zero means no code was accepted yet. Unused for other authentication methods.'; + COMMIT; diff --git a/src/stasis/test_anastasis_db.c b/src/stasis/test_anastasis_db.c @@ -132,6 +132,32 @@ run (void *cls) method, rel_time)); + /* A TOTP time step may be consumed at most once, so that a code cannot be + replayed for as long as its window lasts. */ + { + struct ANASTASIS_CRYPTO_TruthUUIDP unknown_uuid; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + ANASTASIS_DB_update_totp_counter (&truth_uuid, + 1000)); + /* the same step again is the replay we are guarding against */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + ANASTASIS_DB_update_totp_counter (&truth_uuid, + 1000)); + /* an earlier step, as a client with a slow clock would send */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + ANASTASIS_DB_update_totp_counter (&truth_uuid, + 999)); + /* the next step is a fresh code and must still work */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + ANASTASIS_DB_update_totp_counter (&truth_uuid, + 1001)); + memset (&unknown_uuid, 42, sizeof (unknown_uuid)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + ANASTASIS_DB_update_totp_counter (&unknown_uuid, + 1)); + } + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != ANASTASIS_DB_get_recdoc_payment ( &paymentSecretP, diff --git a/src/stasis/update_totp_counter.c b/src/stasis/update_totp_counter.c @@ -0,0 +1,62 @@ +/* + This file is part of Anastasis + Copyright (C) 2026 Anastasis SARL + + Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + Anastasis; see the file COPYING.GPL. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file stasis/update_totp_counter.c + * @brief Anastasis database: accept a TOTP time step counter once + * @author Christian Grothoff + */ +#include "platform.h" +#include "anastasis-db_pg.h" +#include "anastasis/anastasis-database/update_totp_counter.h" +#include "anastasis/anastasis-database/transaction.h" +#include "anastasis/anastasis-database/preflight.h" +#include <taler/taler_pq_lib.h> + + +/** + * Consume the TOTP time step @a counter for @a truth_uuid. + * + * The comparison and the store are one statement on purpose: the row lock + * taken by the UPDATE is what keeps two requests that carry the same code from + * both being told they won. + * + * @param truth_uuid identification of the challenge the code belongs to + * @param counter time step counter the code was computed for + * @return transaction status + */ +enum GNUNET_DB_QueryStatus +ANASTASIS_DB_update_totp_counter ( + const struct ANASTASIS_CRYPTO_TruthUUIDP *truth_uuid, + uint64_t counter) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (truth_uuid), + GNUNET_PQ_query_param_uint64 (&counter), + GNUNET_PQ_query_param_end + }; + + PREPARE ("update_totp_counter", + "UPDATE anastasis_truth" + " SET last_totp_counter=$2" + " WHERE truth_uuid=$1" + " AND last_totp_counter < $2;"); + return GNUNET_PQ_eval_prepared_non_select (pg->conn, + "update_totp_counter", + params); +} + + +/* end of update_totp_counter.c */