commit ab7ba842d024a75c3e4cbc416591eb419f367d0a
parent f3329b20d1b6d26f4e7b150c9a08fed419eff892
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 5 Aug 2026 19:23:28 +0200
add auditor check for existence of justifications of legitimization outcomes
Diffstat:
5 files changed, 449 insertions(+), 11 deletions(-)
diff --git a/src/auditor/taler-helper-auditor-aml.c b/src/auditor/taler-helper-auditor-aml.c
@@ -19,8 +19,8 @@
* made them
* @author Christian Grothoff
*
- * Two checks, in one transaction and in this order because the second needs
- * the first:
+ * Three checks, in one transaction and in this order, because each is judged
+ * against the result of the one before it:
*
* 1) every row in `aml_staff` must carry a valid signature by the exchange's
* *offline* master key. Without this an exchange could appoint an AML
@@ -31,10 +31,17 @@
* officer who, at the time of the decision, was appointed and had
* read-write access.
*
- * Neither finding is a loss: an AML decision moves no money, so a bad
- * signature here costs the exchange nothing directly. What it costs is the
- * point of having officers sign at all, so the findings are reported as row
- * inconsistencies rather than as bad-sig losses.
+ * 3) every row in `legitimization_outcomes` must have something that accounts
+ * for it. That table holds the rules currently in force for an account,
+ * so it is what decides whether a customer's transaction is allowed; a row
+ * that nothing produced is precisely how an exchange would quietly exempt
+ * a customer from KYC. See #check_outcome_cb() for what counts.
+ *
+ * None of the findings is a loss: neither an AML decision nor a rule change
+ * moves money, so a bad signature or an unaccounted-for rule set costs the
+ * exchange nothing directly. What it costs is the point of having officers
+ * sign at all, so the findings are reported as row inconsistencies rather
+ * than as bad-sig losses.
*
* `aml_staff` is append-only -- a status change is a new row -- so the
* exchange's own database does answer "was this officer allowed to decide at
@@ -71,6 +78,7 @@
#include "auditor-database/update_auditor_progress.h"
#include "exchange-database/iterate_aml_history_above_serial_id.h"
#include "exchange-database/iterate_aml_staff_above_serial_id.h"
+#include "exchange-database/iterate_legitimization_outcomes_above_serial_id.h"
/**
@@ -89,6 +97,12 @@ static TALER_ARL_DEF_PP (aml_history_serial_id);
static TALER_ARL_DEF_PP (aml_staff_uuid);
/**
+ * Row of `legitimization_outcomes` up to which we have checked that the
+ * rules in force for an account are accounted for.
+ */
+static TALER_ARL_DEF_PP (legitimization_outcome_serial_id);
+
+/**
* Run in test mode. Exit when idle instead of
* going to sleep and waiting for more work.
*/
@@ -608,6 +622,75 @@ check_decision_cb (void *cls,
/**
+ * Function called with a row of `legitimization_outcomes`, the table that
+ * says which KYC rules currently apply to an account. Checks that something
+ * in the exchange's books accounts for the row existing.
+ *
+ * The exchange has exactly three ways to create one, and each leaves its own
+ * trace:
+ *
+ * - an AML officer decides, and `exchange_do_insert_aml_decision()` writes an
+ * `aml_history` row pointing at the outcome. #check_decision_cb() has
+ * already checked that decision's signature and the officer's appointment,
+ * so this is the strongest of the three;
+ * - a customer passes (or fails) a KYC check, and the AML program's verdict
+ * on it becomes the outcome. What is left of the check is a row in
+ * `legitimization_processes` for the same account, started no later than
+ * the decision;
+ * - the rules in force expire, and `exchange_do_insert_successor_measure()`
+ * or a re-run of the AML program replaces them. What is left of that is
+ * the expired predecessor outcome.
+ *
+ * A row with none of the three was written by something other than the
+ * exchange's own code. Note that the third alternative is the weak one: an
+ * account that has ever had an outcome expire can be given further outcomes
+ * without any fresh justification, exactly as the exchange legitimately does
+ * on expiry. The check is a floor, not a proof that the rules are right.
+ *
+ * @param cls NULL
+ * @param rowid row in `legitimization_outcomes`
+ * @param h_payto account the outcome is about
+ * @param decision_time when the outcome was decided, as claimed
+ * @param expiration_time when the outcome expires
+ * @param has_aml_decision an `aml_history` row points at this outcome
+ * @param has_legitimization_process the account had a legitimization process
+ * that had started by @a decision_time
+ * @param has_expired_predecessor an earlier outcome for the account had
+ * expired by @a decision_time
+ * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+check_outcome_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ struct GNUNET_TIME_Timestamp decision_time,
+ struct GNUNET_TIME_Timestamp expiration_time,
+ bool has_aml_decision,
+ bool has_legitimization_process,
+ bool has_expired_predecessor)
+{
+ (void) cls;
+ (void) h_payto;
+ (void) expiration_time;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Analyzing legitimization outcome %llu decided at %s\n",
+ (unsigned long long) rowid,
+ GNUNET_TIME_timestamp2s (decision_time));
+ TALER_ARL_USE_PP (legitimization_outcome_serial_id) = rowid + 1;
+ if (has_aml_decision ||
+ has_legitimization_process ||
+ has_expired_predecessor)
+ return GNUNET_OK;
+ if (! report_row ("legitimization_outcomes",
+ rowid,
+ "KYC rules in force without an AML decision,"
+ " a legitimization process or an expired predecessor"))
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
* Free a `struct StaffMember`.
*
* @param cls NULL
@@ -673,6 +756,7 @@ analyze_aml (void *cls)
TALER_ARL_adb,
TALER_ARL_GET_PP (aml_history_serial_id),
TALER_ARL_GET_PP (aml_staff_uuid),
+ TALER_ARL_GET_PP (legitimization_outcome_serial_id),
NULL);
if (0 > qs)
{
@@ -683,12 +767,15 @@ analyze_aml (void *cls)
key whether or not the key is on file, so the query status says nothing
about whether we have run before. */
had_pp = (0 != TALER_ARL_USE_PP (aml_staff_uuid)) ||
- (0 != TALER_ARL_USE_PP (aml_history_serial_id));
+ (0 != TALER_ARL_USE_PP (aml_history_serial_id)) ||
+ (0 != TALER_ARL_USE_PP (legitimization_outcome_serial_id));
if (had_pp)
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
- "Resuming AML audit at %llu/%llu\n",
+ "Resuming AML audit at %llu/%llu/%llu\n",
(unsigned long long) TALER_ARL_USE_PP (aml_staff_uuid),
- (unsigned long long) TALER_ARL_USE_PP (aml_history_serial_id));
+ (unsigned long long) TALER_ARL_USE_PP (aml_history_serial_id),
+ (unsigned long long) TALER_ARL_USE_PP (
+ legitimization_outcome_serial_id));
else
GNUNET_log (GNUNET_ERROR_TYPE_MESSAGE,
"First analysis using AML auditor, starting audit from scratch\n");
@@ -733,7 +820,27 @@ analyze_aml (void *cls)
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
"Analyzed %d AML decisions\n",
(int) qs);
- /* Insert first (a no-op once the rows exist), then update: the two
+
+ /* Runs after the decisions: an outcome an AML officer signed is accounted
+ for by the `aml_history' row we just judged, so leaving that judgement
+ for a later round would report the outcome against an incomplete
+ picture. */
+ qs = TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id (
+ TALER_ARL_edb,
+ TALER_ARL_USE_PP (legitimization_outcome_serial_id),
+ &check_outcome_cb,
+ NULL);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ return qs;
+ }
+ if (0 > global_qs)
+ return global_qs;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Analyzed %d legitimization outcomes\n",
+ (int) qs);
+ /* Insert first (a no-op once the rows exist), then update: the three
progress points are written on the very first round as well as on
every later one. Branching on @e had_pp instead would leave the
rows uncreated for ever, and the helper would re-audit the whole
@@ -742,6 +849,7 @@ analyze_aml (void *cls)
TALER_ARL_adb,
TALER_ARL_SET_PP (aml_history_serial_id),
TALER_ARL_SET_PP (aml_staff_uuid),
+ TALER_ARL_SET_PP (legitimization_outcome_serial_id),
NULL);
if (0 > qs)
{
@@ -754,6 +862,7 @@ analyze_aml (void *cls)
TALER_ARL_adb,
TALER_ARL_SET_PP (aml_history_serial_id),
TALER_ARL_SET_PP (aml_staff_uuid),
+ TALER_ARL_SET_PP (legitimization_outcome_serial_id),
NULL);
if (0 > qs)
{
diff --git a/src/auditor/test-kyc.sh b/src/auditor/test-kyc.sh
@@ -49,7 +49,7 @@ set -eu
# Set of numbers for all the testcases.
# When adding new tests, increase the last number:
-ALL_TESTS=$(seq 0 12)
+ALL_TESTS=$(seq 0 13)
# $TESTS determines which tests we should run.
# This construction is used to make it easy to
@@ -1315,6 +1315,62 @@ function test_12() {
}
+# `legitimization_outcomes' is the table that says which KYC rules apply to
+# an account, so a row nothing produced is how an exchange would quietly
+# exempt a customer from KYC. The account here is fresh: it has no AML
+# decision pointing at the outcome, no legitimization process, and no earlier
+# outcome that expired, which is all three of the ways the exchange's own
+# code creates one.
+function test_13() {
+
+ echo "===========13: legitimization outcome without a justification==========="
+ echo -n "Modifying database: "
+# shellcheck disable=SC2028
+ echo "INSERT INTO exchange.kyc_targets
+ (h_normalized_payto, is_wallet)
+ VALUES
+ ('\x5555555555555555555555555555555555555555555555555555555555555555',
+ FALSE);
+ INSERT INTO exchange.legitimization_outcomes
+ (h_payto, decision_time, expiration_time, to_investigate)
+ VALUES
+ ('\x5555555555555555555555555555555555555555555555555555555555555555',
+ 1000000000000000, 2000000000000000, FALSE);" \
+ | psql -Aqt "$DB"
+ echo "DONE"
+
+ run_audit
+ check_auditor_running
+
+ echo -n "Checking that the unjustified outcome was flagged... "
+ check_report_any \
+ "row-inconsistency" \
+ "diagnostic" \
+ "KYC rules in force without an AML decision, a legitimization process or an expired predecessor"
+ echo -n "Checking that it was blamed on the right table... "
+ check_report_any \
+ "row-inconsistency" \
+ "row_table" "legitimization_outcomes"
+
+ # Only the injected row is bad. The outcomes the reference database
+ # earned honestly are each accounted for by a legitimization process or
+ # an AML decision, and reporting those too would make the check useless.
+ echo -n "Checking that the honest outcomes were left alone... "
+ BAD=$(jq -r '[.row_inconsistency[]
+ | select(.row_table == "legitimization_outcomes")]
+ | length' \
+ < "${MY_TMP_DIR}/row-inconsistency.json")
+ if [ "$BAD" != "1" ]
+ then
+ exit_fail "wanted exactly 1 bad legitimization outcome, got $BAD"
+ fi
+ echo "PASS"
+
+ full_reload
+ cleanup
+}
+
+
# *************** Main test loop starts here **************
diff --git a/src/exchangedb/iterate_legitimization_outcomes_above_serial_id.c b/src/exchangedb/iterate_legitimization_outcomes_above_serial_id.c
@@ -0,0 +1,181 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/exchangedb/iterate_legitimization_outcomes_above_serial_id.c
+ * @brief Implementation of the iterate_legitimization_outcomes_above_serial_id function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "helper.h"
+#include "exchange-database/iterate_legitimization_outcomes_above_serial_id.h"
+
+
+/**
+ * Closure for #legitimization_outcome_cb().
+ */
+struct LegitimizationOutcomeContext
+{
+ /**
+ * Function to call for each outcome.
+ */
+ TALER_EXCHANGEDB_LegitimizationOutcomeCallback cb;
+
+ /**
+ * Closure for @e cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Query status to return.
+ */
+ enum GNUNET_DB_QueryStatus qs;
+};
+
+
+/**
+ * Helper function for
+ * #TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id().
+ * To be called with the results of a SELECT statement
+ * that has returned @a num_results results.
+ *
+ * @param cls closure of type `struct LegitimizationOutcomeContext *`
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+legitimization_outcome_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct LegitimizationOutcomeContext *loc = cls;
+
+ for (unsigned int i = 0; i < num_results; i++)
+ {
+ uint64_t rowid;
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct GNUNET_TIME_Timestamp decision_time;
+ struct GNUNET_TIME_Timestamp expiration_time;
+ bool has_aml_decision;
+ bool has_legitimization_process;
+ bool has_expired_predecessor;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("outcome_serial_id",
+ &rowid),
+ GNUNET_PQ_result_spec_auto_from_type ("h_payto",
+ &h_payto),
+ GNUNET_PQ_result_spec_timestamp ("decision_time",
+ &decision_time),
+ GNUNET_PQ_result_spec_timestamp ("expiration_time",
+ &expiration_time),
+ GNUNET_PQ_result_spec_bool ("has_aml_decision",
+ &has_aml_decision),
+ GNUNET_PQ_result_spec_bool ("has_legitimization_process",
+ &has_legitimization_process),
+ GNUNET_PQ_result_spec_bool ("has_expired_predecessor",
+ &has_expired_predecessor),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_GenericReturnValue rval;
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ loc->qs = GNUNET_DB_STATUS_HARD_ERROR;
+ return;
+ }
+ loc->qs = i + 1;
+ rval = loc->cb (loc->cb_cls,
+ rowid,
+ &h_payto,
+ decision_time,
+ expiration_time,
+ has_aml_decision,
+ has_legitimization_process,
+ has_expired_predecessor);
+ GNUNET_PQ_cleanup_result (rs);
+ if (GNUNET_OK != rval)
+ break;
+ }
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint64_t serial_id,
+ TALER_EXCHANGEDB_LegitimizationOutcomeCallback cb,
+ TALER_EXCHANGEDB_LEGITIMIZATION_OUTCOME_RESULT_CLOSURE *cb_cls)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&serial_id),
+ GNUNET_PQ_query_param_end
+ };
+ struct LegitimizationOutcomeContext loc = {
+ .cb = cb,
+ .cb_cls = cb_cls
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ /* The three EXISTS clauses are the three ways the exchange's own code
+ creates an outcome; the caller decides what to make of them.
+
+ `decision_time' is a *rounded* timestamp while `start_time' is not, so a
+ process started at the very moment of the decision can have a start_time
+ up to a second past it. Hence the second of slack, subtracted from
+ start_time rather than added to decision_time so that a decision_time of
+ "forever" does not overflow. */
+ PREPARE (pg,
+ "iterate_legitimization_outcomes_above_serial_id",
+ "SELECT"
+ " lo.outcome_serial_id"
+ ",lo.h_payto"
+ ",lo.decision_time"
+ ",lo.expiration_time"
+ ",EXISTS ("
+ " SELECT 1"
+ " FROM aml_history ah"
+ " WHERE ah.outcome_serial_id=lo.outcome_serial_id"
+ " ) AS has_aml_decision"
+ ",EXISTS ("
+ " SELECT 1"
+ " FROM legitimization_processes lp"
+ " WHERE lp.h_payto=lo.h_payto"
+ " AND lp.start_time-1000000<lo.decision_time"
+ " ) AS has_legitimization_process"
+ ",EXISTS ("
+ " SELECT 1"
+ " FROM legitimization_outcomes pre"
+ " WHERE pre.h_payto=lo.h_payto"
+ " AND pre.outcome_serial_id<lo.outcome_serial_id"
+ " AND pre.expiration_time<=lo.decision_time"
+ " ) AS has_expired_predecessor"
+ " FROM legitimization_outcomes lo"
+ " WHERE lo.outcome_serial_id>=$1"
+ " ORDER BY lo.outcome_serial_id ASC;");
+ qs = GNUNET_PQ_eval_prepared_multi_select (
+ pg->conn,
+ "iterate_legitimization_outcomes_above_serial_id",
+ params,
+ &legitimization_outcome_cb,
+ &loc);
+ if (qs > 0)
+ return loc.qs;
+ GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR != qs);
+ return qs;
+}
diff --git a/src/exchangedb/meson.build b/src/exchangedb/meson.build
@@ -156,6 +156,7 @@ libtalerexchangedb = library(
'iterate_aml_history.c',
'iterate_aml_history_above_serial_id.c',
'iterate_aml_staff_above_serial_id.c',
+ 'iterate_legitimization_outcomes_above_serial_id.c',
'get_aml_officer.c',
'get_auditor_status.c',
'get_auditor_timestamp.c',
diff --git a/src/include/exchange-database/iterate_legitimization_outcomes_above_serial_id.h b/src/include/exchange-database/iterate_legitimization_outcomes_above_serial_id.h
@@ -0,0 +1,91 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/exchange-database/iterate_legitimization_outcomes_above_serial_id.h
+ * @brief implementation of the iterate_legitimization_outcomes_above_serial_id function for Postgres
+ * @author Christian Grothoff
+ */
+#ifndef EXCHANGE_DATABASE_ITERATE_LEGITIMIZATION_OUTCOMES_ABOVE_SERIAL_ID_H
+#define EXCHANGE_DATABASE_ITERATE_LEGITIMIZATION_OUTCOMES_ABOVE_SERIAL_ID_H
+
+#include "taler/taler_util.h"
+#include "taler/taler_json_lib.h"
+#include "exchangedb_lib.h"
+
+
+#ifndef TALER_EXCHANGEDB_LEGITIMIZATION_OUTCOME_RESULT_CLOSURE
+/**
+ * Type of the closure for #TALER_EXCHANGEDB_LegitimizationOutcomeCallback.
+ */
+#define TALER_EXCHANGEDB_LEGITIMIZATION_OUTCOME_RESULT_CLOSURE void
+#endif
+
+/* Callback typedefs */
+/**
+ * Function called with a legitimization outcome, together with the three
+ * things that could have brought it about. The flags are facts about the
+ * database, not a verdict: which combinations of them an honest exchange can
+ * produce is for the caller to decide.
+ *
+ * @param cls closure
+ * @param rowid unique serial ID of the entry in legitimization_outcomes
+ * @param h_payto normalized payto hash of the account the outcome is about
+ * @param decision_time when the outcome was decided, as claimed
+ * @param expiration_time when the outcome expires and its successor measure
+ * is due
+ * @param has_aml_decision true if an `aml_history` row points at this
+ * outcome, that is, an AML officer signed it
+ * @param has_legitimization_process true if the account had a legitimization
+ * process that had started by @a decision_time, that is, the outcome
+ * could be an AML program's verdict on a check the customer went
+ * through
+ * @param has_expired_predecessor true if an earlier outcome for the same
+ * account had expired by @a decision_time, that is, the outcome could
+ * be the successor measure that expiry called for
+ * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop
+ */
+typedef enum GNUNET_GenericReturnValue
+(*TALER_EXCHANGEDB_LegitimizationOutcomeCallback)(
+ TALER_EXCHANGEDB_LEGITIMIZATION_OUTCOME_RESULT_CLOSURE *cls,
+ uint64_t rowid,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ struct GNUNET_TIME_Timestamp decision_time,
+ struct GNUNET_TIME_Timestamp expiration_time,
+ bool has_aml_decision,
+ bool has_legitimization_process,
+ bool has_expired_predecessor);
+
+
+/**
+ * Select legitimization outcomes above @a serial_id in monotonically
+ * increasing order, each with flags saying what in the database could
+ * account for it.
+ *
+ * @param pg the database context
+ * @param serial_id lowest serial ID to include (the row itself is returned,
+ * so callers resuming from a progress point pass last seen + 1)
+ * @param cb function to call on each result
+ * @param cb_cls closure for @a cb
+ * @return transaction status code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint64_t serial_id,
+ TALER_EXCHANGEDB_LegitimizationOutcomeCallback cb,
+ TALER_EXCHANGEDB_LEGITIMIZATION_OUTCOME_RESULT_CLOSURE *cb_cls);
+
+#endif