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