exchange

Base system with REST service to issue digital coins, run by the payment service provider
Log | Files | Refs | Submodules | README | LICENSE

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:
Msrc/auditor/taler-helper-auditor-aml.c | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/auditor/test-kyc.sh | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/exchangedb/iterate_legitimization_outcomes_above_serial_id.c | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchangedb/meson.build | 1+
Asrc/include/exchange-database/iterate_legitimization_outcomes_above_serial_id.h | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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