exchange

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

commit fbfda27769502e5592bbb7457983637f2fb667c3
parent a320eddc25461a3a853c4d5c72d5dea8f66be4fe
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat,  1 Aug 2026 22:46:18 +0200

make AML officer status changes append-only, add auditor to check AML officer decision signatures and appointments

Diffstat:
Mdebian/rules | 1+
Adebian/taler-auditor.taler-helper-auditor-aml.service | 28++++++++++++++++++++++++++++
Mmeson.build | 2+-
Msrc/auditor/meson.build | 19+++++++++++++++++++
Asrc/auditor/taler-helper-auditor-aml.c | 919+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/auditor/test-auditor.sh | 18++++++++++++++++++
Msrc/auditor/test-kyc.sh | 119++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/auditor/test-revocation.sh | 2+-
Asrc/auditordb/0003-auditor_aml_staff.sql | 40++++++++++++++++++++++++++++++++++++++++
Asrc/auditordb/insert_aml_staff.c | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/auditordb/iterate_aml_staff.c | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/auditordb/meson.build | 3+++
Msrc/auditordb/restart.sql | 1+
Msrc/exchangedb/get_aml_officer.c | 6+++++-
Msrc/exchangedb/get_exists_aml_officer.c | 14+++++++++++---
Msrc/exchangedb/insert_aml_decision.sql | 15+++++++++++----
Msrc/exchangedb/insert_aml_officer.sql | 58+++++++++++++++++++++++++++++-----------------------------
Msrc/exchangedb/iterate_aml_attributes.c | 24++++++++++++++++++------
Asrc/exchangedb/iterate_aml_history_above_serial_id.c | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/iterate_aml_staff_above_serial_id.c | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchangedb/meson.build | 2++
Asrc/exchangedb/sql-schema/0011-aml_staff.sql | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/sql-schema/0011-preamble.sql | 20++++++++++++++++++++
Asrc/exchangedb/sql-schema/auditor-triggers-0002.sql | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchangedb/sql-schema/meson.build | 4++++
Asrc/include/auditor-database/insert_aml_staff.h | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/auditor-database/iterate_aml_staff.h | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/iterate_aml_history_above_serial_id.h | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/iterate_aml_staff_above_serial_id.h | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler/taler_crypto_lib.h | 40++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler/taler_dbevents.h | 6++++++
Msrc/util/aml_signatures.c | 43++++++++++++++++++++++++++++++++++++++-----
32 files changed, 2341 insertions(+), 51 deletions(-)

diff --git a/debian/rules b/debian/rules @@ -43,6 +43,7 @@ override_dh_installsystemd: dh_installsystemd -ptaler-exchange --name=taler-exchange --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -ptaler-exchange --name=taler-exchange-sanctionscheck --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -ptaler-auditor --name=taler-auditor-httpd --no-start --no-enable --no-stop-on-upgrade + dh_installsystemd -ptaler-auditor --name=taler-helper-auditor-aml --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -ptaler-auditor --name=taler-helper-auditor-aggregation --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -ptaler-auditor --name=taler-helper-auditor-coins --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -ptaler-auditor --name=taler-helper-auditor-deposits --no-start --no-enable --no-stop-on-upgrade diff --git a/debian/taler-auditor.taler-helper-auditor-aml.service b/debian/taler-auditor.taler-helper-auditor-aml.service @@ -0,0 +1,27 @@ +[Unit] +Description=GNU Taler auditor helper verifying AML officer signatures +After=postgres.service +PartOf=taler-auditor.target + +[Service] +User=taler-auditor-httpd +Type=simple +Restart=always +RestartSec=1s +RestartPreventExitStatus=9 +ExecStart=/usr/bin/taler-helper-auditor-aml -c /etc/taler-auditor/taler-auditor.conf -L INFO +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=full +RuntimeMaxSec=3600s + +StandardOutput=journal +StandardError=journal +Slice=taler-auditor.slice + +# Disable the service if more than 5 restarts are encountered within 5s. +# These are usually the systemd defaults, but can be overwritten, thus we set +# them here explicitly, as the exchange code assumes StartLimitInterval +# to be >=5s. +StartLimitBurst=5 +StartLimitInterval=5s +\ No newline at end of file diff --git a/meson.build b/meson.build @@ -281,7 +281,7 @@ if not get_option('only-doc') libltversions = [ - ['libtalerutil', '15:0:1'], + ['libtalerutil', '16:0:2'], ['libtalerjson', '7:0:3'], ['libtalercurl', '0:1:0'], ['libtalerpq', '1:0:0'], diff --git a/src/auditor/meson.build b/src/auditor/meson.build @@ -81,6 +81,25 @@ executable( executable( + 'taler-helper-auditor-aml', + ['taler-helper-auditor-aml.c'], + dependencies: [ + libtalerutil_dep, + libtalerjson_dep, + libtalerexchangedb_dep, + libtalerauditordb_dep, + libauditorreport_dep, + gnunetutil_dep, + gnunetjson_dep, + json_dep, + gcrypt_dep, + ], + include_directories: [incdir, configuration_inc], + install: true, +) + + +executable( 'taler-helper-auditor-deposits', ['taler-helper-auditor-deposits.c'], dependencies: [ diff --git a/src/auditor/taler-helper-auditor-aml.c b/src/auditor/taler-helper-auditor-aml.c @@ -0,0 +1,919 @@ +/* + 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 Affero 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 Affero Public License for more details. + + You should have received a copy of the GNU Affero Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file auditor/taler-helper-auditor-aml.c + * @brief audits the AML decisions and the appointments of the AML staff who + * made them + * @author Christian Grothoff + * + * Two checks, in one transaction and in this order because the second needs + * the first: + * + * 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 + * officer -- or quietly widen a read-only officer to read-write -- with + * the offline key never having been involved. + * + * 2) every AML decision in `aml_history` must carry a valid signature by an + * 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. + * + * `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 + * that time?". The auditor nevertheless keeps its own record of every status + * change it has seen, in `auditor_aml_staff`, and answers the question from + * that: append-only is a promise the exchange's code makes, not one the + * database enforces against the exchange's operator, and an independent + * record is the point of an auditor. + * + * That record is also what makes backdating detectable. The master key is + * the exchange operator's own, so a dishonest operator can sign an + * appointment naming any `last_change` it likes, and a decision naming any + * `decision_time`. What it cannot do is change what the auditor already + * saw: if the auditor recorded an officer as disabled before this round + * began, a decision by that officer surfacing now is backdated, whatever + * `decision_time` claims. This does not stop backdating *within* the set of + * rows processed in one round -- an auditor running promptly keeps that + * window small, which is the whole defence. + */ +#include "platform.h" +#include <gnunet/gnunet_util_lib.h> +#include "auditordb_lib.h" +#include "exchangedb_lib.h" +#include "report-lib.h" +#include "taler/taler_dbevents.h" +#include <jansson.h> +#include <inttypes.h> +#include "auditor-database/event_listen.h" +#include "auditor-database/get_auditor_progress.h" +#include "auditor-database/insert_aml_staff.h" +#include "auditor-database/insert_auditor_progress.h" +#include "auditor-database/insert_row_inconsistency.h" +#include "auditor-database/iterate_aml_staff.h" +#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" + + +/** + * Return value from main(). + */ +static int global_ret; + +/** + * Row of `aml_history` up to which we have checked AML decisions. + */ +static TALER_ARL_DEF_PP (aml_history_serial_id); + +/** + * Row of `aml_staff` up to which we have checked status changes. + */ +static TALER_ARL_DEF_PP (aml_staff_uuid); + +/** + * Run in test mode. Exit when idle instead of + * going to sleep and waiting for more work. + */ +static int test_mode; + +/** + * Should we run checks that only work for exchange-internal audits? + * Does nothing for this helper (present only for uniformity). + */ +static int internal_checks; + +/** + * Handle to the database event we wait on when running resident. + */ +static struct GNUNET_DB_EventHandler *eh; + +/** + * The auditors's configuration. + */ +static const struct GNUNET_CONFIGURATION_Handle *cfg; + +/** + * Map from the hash of an officer's public key to a `struct StaffMember`. + */ +static struct GNUNET_CONTAINER_MultiHashMap *staff_map; + +/** + * Status of the DB operations of our callbacks; they cannot return a + * query status themselves. + */ +static enum GNUNET_DB_QueryStatus global_qs; + + +/** + * One observed status of an AML staff member, in force from @e last_change + * until the next status of the same member. + */ +struct StaffStatus +{ + /** + * When this status took effect, as claimed by the exchange. + */ + struct GNUNET_TIME_Timestamp last_change; + + /** + * Was the member allowed to act? + */ + bool is_active; + + /** + * Was the member restricted to read-only access? + */ + bool read_only; + + /** + * Did the exchange's offline master key actually sign this status? + * A status that fails this check confers no authority. + */ + bool master_sig_valid; +}; + + +/** + * What we know about one AML staff member. + */ +struct StaffMember +{ + /** + * Key under which this member is stored in #staff_map. The map is + * created with @a do_not_copy_keys, so it keeps this pointer: the key + * has to live as long as the entry does. + */ + struct GNUNET_HashCode key; + + /** + * Observed statuses, oldest first. + */ + struct StaffStatus *statuses; + + /** + * Length of @e statuses. + */ + unsigned int num_statuses; + + /** + * Did we already know this member when the round began? + */ + bool known_at_start; + + /** + * Was the member enabled according to the most recent status we knew + * when the round began? Meaningless unless @e known_at_start. + */ + bool active_at_start; + + /** + * Did we observe the member being enabled during this round? + */ + bool enabled_this_round; +}; + + +/** + * Compute the map key for @a decider_pub. + * + * @param decider_pub public key of the staff member + * @param[out] key set to the key to use + */ +static void +staff_key (const struct TALER_AmlOfficerPublicKeyP *decider_pub, + struct GNUNET_HashCode *key) +{ + GNUNET_CRYPTO_hash (decider_pub, + sizeof (*decider_pub), + key); +} + + +/** + * Look up @a decider_pub in #staff_map, creating the entry if needed. + * + * @param decider_pub public key of the staff member + * @return the entry, never NULL + */ +static struct StaffMember * +staff_member_get (const struct TALER_AmlOfficerPublicKeyP *decider_pub) +{ + struct GNUNET_HashCode key; + struct StaffMember *sm; + + staff_key (decider_pub, + &key); + sm = GNUNET_CONTAINER_multihashmap_get (staff_map, + &key); + if (NULL != sm) + return sm; + sm = GNUNET_new (struct StaffMember); + sm->key = key; + GNUNET_assert (GNUNET_OK == + GNUNET_CONTAINER_multihashmap_put ( + staff_map, + &sm->key, + sm, + GNUNET_CONTAINER_MULTIHASHMAPOPTION_UNIQUE_ONLY)); + return sm; +} + + +/** + * Append @a status to the statuses of @a sm, keeping them ordered by + * @e last_change. + * + * @param[in,out] sm staff member to extend + * @param status status to add + */ +static void +staff_member_add (struct StaffMember *sm, + const struct StaffStatus *status) +{ + unsigned int i; + + GNUNET_array_grow (sm->statuses, + sm->num_statuses, + sm->num_statuses + 1); + for (i = sm->num_statuses - 1; i > 0; i--) + { + if (GNUNET_TIME_timestamp_cmp (sm->statuses[i - 1].last_change, + <=, + status->last_change)) + break; + sm->statuses[i] = sm->statuses[i - 1]; + } + sm->statuses[i] = *status; +} + + +/** + * Find the status that was in force for @a sm at @a when, considering only + * statuses the offline master key really signed. + * + * @param sm staff member to look at + * @param when point in time of interest + * @return NULL if no signed status covers @a when + */ +static const struct StaffStatus * +staff_member_status_at (const struct StaffMember *sm, + struct GNUNET_TIME_Timestamp when) +{ + const struct StaffStatus *ret = NULL; + + for (unsigned int i = 0; i < sm->num_statuses; i++) + { + const struct StaffStatus *ss = &sm->statuses[i]; + + if (GNUNET_TIME_timestamp_cmp (ss->last_change, + >, + when)) + break; /* sorted, so no later entry can apply either */ + if (ss->master_sig_valid) + ret = ss; + } + return ret; +} + + +/** + * Report a row inconsistency. + * + * @param table name of the exchange table the bad row is in + * @param rowid row that is bad + * @param diagnostic what is wrong with it + * @return true on success, false if the database failed us + */ +static bool +report_row (const char *table, + uint64_t rowid, + const char *diagnostic) +{ + struct TALER_AUDITORDB_RowInconsistency ri = { + .row_id = rowid, + .row_table = (char *) table, + .diagnostic = (char *) diagnostic + }; + enum GNUNET_DB_QueryStatus qs; + + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Row %llu of `%s' is bad: %s\n", + (unsigned long long) rowid, + table, + diagnostic); + qs = TALER_AUDITORDB_insert_row_inconsistency (TALER_ARL_adb, + &ri); + if (0 > qs) + { + GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs); + global_qs = qs; + return false; + } + return true; +} + + +/** + * Function called with an AML staff status change we recorded earlier. + * Rebuilds our view of who was allowed to decide when. + * + * @param cls NULL + * @param decider_pub public key of the staff member + * @param decider_name legal name of the staff member + * @param is_active true if the member could act from @a last_change on + * @param read_only true if the member had read-only access + * @param master_sig_valid true if the master key really signed this status + * @param last_change when the status took effect + * @return #GNUNET_OK to continue to iterate + */ +static enum GNUNET_GenericReturnValue +known_staff_cb (void *cls, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const char *decider_name, + bool is_active, + bool read_only, + bool master_sig_valid, + struct GNUNET_TIME_Timestamp last_change) +{ + struct StaffMember *sm = staff_member_get (decider_pub); + struct StaffStatus ss = { + .last_change = last_change, + .is_active = is_active, + .read_only = read_only, + .master_sig_valid = master_sig_valid + }; + + (void) cls; + (void) decider_name; + staff_member_add (sm, + &ss); + /* We are called oldest-first, so the last call for a member leaves the + status that was in force when this round began. */ + sm->known_at_start = true; + sm->active_at_start = is_active && master_sig_valid; + return GNUNET_OK; +} + + +/** + * Function called with a status change of an AML staff member the exchange + * recorded. Verifies the offline master key's signature on it and, if this + * is a status we have not seen before, records it. + * + * @param cls NULL + * @param rowid row in `aml_staff` + * @param decider_pub public key of the staff member + * @param master_sig signature by the offline master key, NULL if absent + * @param decider_name legal name of the staff member + * @param is_active true if the member may currently act + * @param read_only true if the member has read-only access + * @param last_change when the status took effect, as claimed + * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +check_staff_cb (void *cls, + uint64_t rowid, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const struct TALER_MasterSignatureP *master_sig, + const char *decider_name, + bool is_active, + bool read_only, + struct GNUNET_TIME_Timestamp last_change) +{ + struct StaffMember *sm = staff_member_get (decider_pub); + struct StaffStatus ss = { + .last_change = last_change, + .is_active = is_active, + .read_only = read_only, + .master_sig_valid = false + }; + enum GNUNET_DB_QueryStatus qs; + bool regression = false; + + (void) cls; + TALER_ARL_USE_PP (aml_staff_uuid) = rowid + 1; + for (unsigned int i = 0; i < sm->num_statuses; i++) + { + if (GNUNET_TIME_timestamp_cmp (sm->statuses[i].last_change, + ==, + last_change)) + return GNUNET_OK; /* already seen and judged in an earlier round */ + if (GNUNET_TIME_timestamp_cmp (sm->statuses[i].last_change, + >, + last_change)) + regression = true; + } + if (NULL == master_sig) + { + if (! report_row ("aml_staff", + rowid, + "staff status without a master key signature")) + return GNUNET_SYSERR; + } + else if (GNUNET_OK != + TALER_exchange_offline_aml_officer_status_verify ( + decider_pub, + decider_name, + last_change, + is_active, + read_only, + &TALER_ARL_master_pub, + master_sig)) + { + if (! report_row ("aml_staff", + rowid, + "invalid master key signature on staff status") + ) + return GNUNET_SYSERR; + } + else + { + ss.master_sig_valid = true; + } + if (regression) + { + /* exchange_do_insert_aml_officer() refuses to store a status older than + the one it has, so seeing one means somebody wrote to the table + behind the exchange's back. */ + if (! report_row ("aml_staff", + rowid, + "staff status is older than one seen before")) + return GNUNET_SYSERR; + } + if (ss.master_sig_valid && is_active) + sm->enabled_this_round = true; + staff_member_add (sm, + &ss); + qs = TALER_AUDITORDB_insert_aml_staff (TALER_ARL_adb, + decider_pub, + decider_name, + is_active, + read_only, + ss.master_sig_valid, + last_change); + if (0 > qs) + { + GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs); + global_qs = qs; + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Function called with an AML decision the exchange recorded. Checks the + * officer's signature and that the officer was allowed to make it. + * + * @param cls NULL + * @param rowid row in `aml_history` + * @param h_payto account the decision is about + * @param justification justification given + * @param decider_pub officer who decided, NULL if not recorded + * @param decider_sig the officer's signature, NULL if not recorded + * @param decision_time when the decision was taken, as claimed + * @param jproperties new account properties, NULL for none + * @param jnew_rules new KYC rules, NULL if not recorded + * @param new_measure_name measure to apply, NULL for none + * @param to_investigate whether staff should investigate the account + * @param attributes_expiration when attributes set with the decision expire + * @param h_attributes hash of the attributes set, NULL if none + * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +check_decision_cb (void *cls, + uint64_t rowid, + const struct TALER_NormalizedPaytoHashP *h_payto, + const char *justification, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const struct TALER_AmlOfficerSignatureP *decider_sig, + struct GNUNET_TIME_Timestamp decision_time, + const json_t *jproperties, + const json_t *jnew_rules, + const char *new_measure_name, + bool to_investigate, + struct GNUNET_TIME_Timestamp attributes_expiration, + const struct GNUNET_HashCode *h_attributes) +{ + struct StaffMember *sm; + const struct StaffStatus *ss; + + (void) cls; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Analyzing AML decision %llu taken at %s\n", + (unsigned long long) rowid, + GNUNET_TIME_timestamp2s (decision_time)); + TALER_ARL_USE_PP (aml_history_serial_id) = rowid + 1; + if (NULL == decider_pub) + { + /* exchange_do_insert_aml_decision() only writes an aml_history row when + it has a decider, so this cannot happen without tampering. */ + if (! report_row ("aml_history", + rowid, + "decision without an officer public key")) + return GNUNET_SYSERR; + return GNUNET_OK; + } + if (NULL == decider_sig) + { + if (! report_row ("aml_history", + rowid, + "decision without an officer signature")) + return GNUNET_SYSERR; + } + else if (NULL == jnew_rules) + { + /* The signature is over the new rules, so without them there is nothing + we could check it against. */ + if (! report_row ("aml_history", + rowid, + "decision without the new rules it signed over")) + return GNUNET_SYSERR; + } + else if (GNUNET_OK != + TALER_officer_aml_decision_verify_hashed ( + justification, + decision_time, + h_payto, + jnew_rules, + jproperties, + new_measure_name, + to_investigate, + decider_pub, + decider_sig, + attributes_expiration, + h_attributes)) + { + if (! report_row ("aml_history", + rowid, + "invalid officer signature on decision")) + return GNUNET_SYSERR; + } + sm = staff_member_get (decider_pub); + ss = staff_member_status_at (sm, + decision_time); + if (NULL == ss) + { + if (! report_row ("aml_history", + rowid, + "officer was not appointed when the decision was made")) + return GNUNET_SYSERR; + } + else if (! ss->is_active) + { + if (! report_row ("aml_history", + rowid, + "officer was not active when the decision was made")) + return GNUNET_SYSERR; + } + else if (ss->read_only) + { + if (! report_row ("aml_history", + rowid, + "officer had read-only access when deciding")) + return GNUNET_SYSERR; + } + /* The eligibility check above trusts decision_time, which the exchange + picks. This one does not: whatever the decision claims, a record for an + officer we already knew to be disabled has no business appearing now. */ + if (sm->known_at_start && + (! sm->active_at_start) && + (! sm->enabled_this_round)) + { + if (! report_row ("aml_history", + rowid, + "decision appeared after the officer was disabled")) + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Free a `struct StaffMember`. + * + * @param cls NULL + * @param key unused + * @param value the `struct StaffMember` to free + * @return #GNUNET_OK + */ +static enum GNUNET_GenericReturnValue +free_staff_member (void *cls, + const struct GNUNET_HashCode *key, + void *value) +{ + struct StaffMember *sm = value; + + (void) cls; + (void) key; + GNUNET_assert (GNUNET_YES == + GNUNET_CONTAINER_multihashmap_remove (staff_map, + &sm->key, + sm)); + GNUNET_array_grow (sm->statuses, + sm->num_statuses, + 0); + GNUNET_free (sm); + return GNUNET_OK; +} + + +/** + * Drop our in-memory view of the AML staff. + */ +static void +clear_staff_map (void) +{ + if (NULL == staff_map) + return; + GNUNET_CONTAINER_multihashmap_iterate (staff_map, + &free_staff_member, + NULL); + GNUNET_CONTAINER_multihashmap_destroy (staff_map); + staff_map = NULL; +} + + +/** + * Analyze the AML staff appointments and the decisions they justify. + * + * @param cls NULL + * @return transaction status code + */ +static enum GNUNET_DB_QueryStatus +analyze_aml (void *cls) +{ + enum GNUNET_DB_QueryStatus qs; + bool had_pp; + + (void) cls; + global_qs = GNUNET_DB_STATUS_SUCCESS_NO_RESULTS; + clear_staff_map (); + staff_map = GNUNET_CONTAINER_multihashmap_create (32, + GNUNET_YES); + qs = TALER_AUDITORDB_get_auditor_progress ( + TALER_ARL_adb, + TALER_ARL_GET_PP (aml_history_serial_id), + TALER_ARL_GET_PP (aml_staff_uuid), + NULL); + if (0 > qs) + { + GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs); + return qs; + } + /* Not from @a qs: auditor_do_get_auditor_progress() returns one row per + 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)); + if (had_pp) + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Resuming AML audit at %llu/%llu\n", + (unsigned long long) TALER_ARL_USE_PP (aml_staff_uuid), + (unsigned long long) TALER_ARL_USE_PP (aml_history_serial_id)); + else + GNUNET_log (GNUNET_ERROR_TYPE_MESSAGE, + "First analysis using AML auditor, starting audit from scratch\n"); + + /* What we knew about the staff when this round began. */ + qs = TALER_AUDITORDB_iterate_aml_staff (TALER_ARL_adb, + &known_staff_cb, + NULL); + if (0 > qs) + { + GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs); + return qs; + } + + /* Status changes the exchange recorded since we last looked. Must run + before the decisions below, which are judged against the result. */ + qs = TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id ( + TALER_ARL_edb, + TALER_ARL_USE_PP (aml_staff_uuid), + &check_staff_cb, + NULL); + if (0 > qs) + { + GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs); + return qs; + } + if (0 > global_qs) + return global_qs; + + qs = TALER_EXCHANGEDB_iterate_aml_history_above_serial_id ( + TALER_ARL_edb, + TALER_ARL_USE_PP (aml_history_serial_id), + &check_decision_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 AML decisions\n", + (int) qs); + /* Insert first (a no-op once the rows exist), then update: the two + 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 + history every time it woke up. */ + qs = TALER_AUDITORDB_insert_auditor_progress ( + TALER_ARL_adb, + TALER_ARL_SET_PP (aml_history_serial_id), + TALER_ARL_SET_PP (aml_staff_uuid), + NULL); + if (0 > qs) + { + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Failed to update auditor DB, not recording progress\n"); + GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs); + return qs; + } + qs = TALER_AUDITORDB_update_auditor_progress ( + TALER_ARL_adb, + TALER_ARL_SET_PP (aml_history_serial_id), + TALER_ARL_SET_PP (aml_staff_uuid), + NULL); + if (0 > qs) + { + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Failed to update auditor DB, not recording progress\n"); + GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs); + return qs; + } + return GNUNET_DB_STATUS_SUCCESS_ONE_RESULT; +} + + +/** + * Function called when the exchange recorded an AML staff status change or + * an AML decision. Re-runs the analysis. + * + * @param cls NULL + * @param extra additional event data provided + * @param extra_size number of bytes in @a extra + */ +static void +db_notify (void *cls, + const void *extra, + size_t extra_size) +{ + (void) cls; + (void) extra; + (void) extra_size; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Received notification for new AML data\n"); + if (GNUNET_OK != + TALER_ARL_setup_sessions_and_run (&analyze_aml, + NULL)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Audit failed\n"); + GNUNET_SCHEDULER_shutdown (); + global_ret = EXIT_FAILURE; + return; + } +} + + +/** + * Function called on shutdown. + * + * @param cls NULL + */ +static void +do_shutdown (void *cls) +{ + (void) cls; + if (NULL != eh) + { + TALER_AUDITORDB_event_listen_cancel (eh); + eh = NULL; + } + clear_staff_map (); + TALER_ARL_done (); +} + + +/** + * Main function that will be run. + * + * @param cls closure + * @param args remaining command-line arguments + * @param cfgfile name of the configuration file used (for saving, can be NULL!) + * @param c configuration + */ +static void +run (void *cls, + char *const *args, + const char *cfgfile, + const struct GNUNET_CONFIGURATION_Handle *c) +{ + (void) cls; + (void) args; + (void) cfgfile; + cfg = c; + GNUNET_SCHEDULER_add_shutdown (&do_shutdown, + NULL); + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "Launching AML auditor\n"); + if (GNUNET_OK != + TALER_ARL_init (c)) + { + global_ret = EXIT_FAILURE; + return; + } + if (test_mode != 1) + { + struct GNUNET_DB_EventHeaderP es = { + .size = htons (sizeof (es)), + .type = htons (TALER_DBEVENT_EXCHANGE_AUDITOR_WAKE_HELPER_AML) + }; + + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Running helper indefinitely\n"); + eh = TALER_AUDITORDB_event_listen (TALER_ARL_adb, + &es, + GNUNET_TIME_UNIT_FOREVER_REL, + &db_notify, + NULL); + } + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "Starting audit\n"); + if (GNUNET_OK != + TALER_ARL_setup_sessions_and_run (&analyze_aml, + NULL)) + { + GNUNET_SCHEDULER_shutdown (); + global_ret = EXIT_FAILURE; + return; + } +} + + +/** + * The main function of the AML auditing helper tool. + * + * @param argc number of arguments from the command line + * @param argv command line arguments + * @return 0 ok, 1 on error + */ +int +main (int argc, + char *const *argv) +{ + const struct GNUNET_GETOPT_CommandLineOption options[] = { + GNUNET_GETOPT_option_flag ('i', + "internal", + "perform checks only applicable for exchange-internal audits", + &internal_checks), + GNUNET_GETOPT_option_flag ('t', + "test", + "run in test mode and exit when idle", + &test_mode), + GNUNET_GETOPT_option_timetravel ('T', + "timetravel"), + GNUNET_GETOPT_OPTION_END + }; + enum GNUNET_GenericReturnValue ret; + + ret = GNUNET_PROGRAM_run ( + TALER_AUDITOR_project_data (), + argc, + argv, + "taler-helper-auditor-aml", + gettext_noop ( + "Audit exchange database for AML decisions made by unauthorised staff"), + options, + &run, + NULL); + if (GNUNET_SYSERR == ret) + return EXIT_INVALIDARGUMENT; + if (GNUNET_NO == ret) + return EXIT_SUCCESS; + return global_ret; +} + + +/* end of taler-helper-auditor-aml.c */ diff --git a/src/auditor/test-auditor.sh b/src/auditor/test-auditor.sh @@ -165,6 +165,24 @@ function audit_only () { taler-auditor-dbinit \ -r \ -c "$CONF" + $VALGRIND taler-helper-auditor-aml \ + -i \ + -L DEBUG \ + -c "$CONF" \ + -t \ + > "${MY_TMP_DIR}/test-audit-aml.out" \ + 2> "${MY_TMP_DIR}/test-audit-aml.err" \ + || exit_fail "aml audit failed (see ${MY_TMP_DIR}/test-audit-aml.*)" + echo -n "." + $VALGRIND taler-helper-auditor-aml \ + -i \ + -L DEBUG \ + -c "$CONF" \ + -t \ + > "${MY_TMP_DIR}/test-audit-aml-inc.out" \ + 2> "${MY_TMP_DIR}/test-audit-aml-inc.err" \ + || exit_fail "incremental aml audit failed (see ${MY_TMP_DIR}/test-audit-aml-inc.*)" + echo -n "." $VALGRIND taler-helper-auditor-aggregation \ -L DEBUG \ -c "$CONF" \ 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 10) +ALL_TESTS=$(seq 0 12) # $TESTS determines which tests we should run. # This construction is used to make it easy to @@ -186,6 +186,24 @@ function audit_only () { taler-auditor-dbinit \ -r \ -c "$CONF" + $VALGRIND taler-helper-auditor-aml \ + -i \ + -L DEBUG \ + -c "$CONF" \ + -t \ + > "${MY_TMP_DIR}/test-audit-aml.out" \ + 2> "${MY_TMP_DIR}/test-audit-aml.err" \ + || exit_fail "aml audit failed (see ${MY_TMP_DIR}/test-audit-aml.*)" + echo -n "." + $VALGRIND taler-helper-auditor-aml \ + -i \ + -L DEBUG \ + -c "$CONF" \ + -t \ + > "${MY_TMP_DIR}/test-audit-aml-inc.out" \ + 2> "${MY_TMP_DIR}/test-audit-aml-inc.err" \ + || exit_fail "incremental aml audit failed (see ${MY_TMP_DIR}/test-audit-aml-inc.*)" + echo -n "." $VALGRIND taler-helper-auditor-aggregation \ -L DEBUG \ -c "$CONF" \ @@ -1198,6 +1216,105 @@ function test_10() { } +# An AML staff appointment that the exchange's offline master key did not +# sign. Without this check an exchange could appoint an AML officer, or +# quietly widen a read-only officer to read-write, with the offline key never +# having been involved. +function test_11() { + + echo "===========11: AML staff appointment with a bad master signature===========" + echo -n "Modifying database: " +# shellcheck disable=SC2028 + echo "INSERT INTO exchange.aml_staff + (decider_pub, master_sig, decider_name, is_active, read_only, last_change) + VALUES + ('\x1111111111111111111111111111111111111111111111111111111111111111', + '\x22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222', + 'Unappointed Officer', TRUE, FALSE, 1000000000000000);" \ + | psql -Aqt "$DB" + echo "DONE" + + run_audit + check_auditor_running + + echo -n "Checking that the appointment was flagged... " + check_report_any \ + "row-inconsistency" \ + "diagnostic" "invalid master key signature on staff status" + echo -n "Checking that it was blamed on the right table... " + check_report_any \ + "row-inconsistency" \ + "row_table" "aml_staff" + + # aml_staff is append-only, so the helper can and must resume from where + # it stopped instead of re-reading the whole table on every wake-up. + # Both halves have to hold: the cursor has to be stored at all, and the + # second run has to pick it up. + echo -n "Checking that the aml_staff cursor was stored... " + AML_PP=$(psql -Aqt "$DB" \ + -c "SELECT progress_offset FROM auditor.auditor_progress \ + WHERE progress_key='aml_staff_uuid';") + if [ -z "$AML_PP" ] || [ "$AML_PP" = "0" ] + then + exit_fail "aml_staff_uuid progress point not recorded (got '$AML_PP')" + fi + echo "PASS ($AML_PP)" + echo -n "Checking that the second run resumed from it... " + if ! grep -q "Resuming AML audit at $AML_PP/" \ + "${MY_TMP_DIR}/test-audit-aml-inc.err" + then + exit_fail "incremental AML audit did not resume at $AML_PP" + fi + echo "PASS" + + full_reload + cleanup +} + + +# An AML decision that no valid officer signature backs, by somebody the +# exchange never appointed at all. Both halves have to be reported: the +# signature is what makes the decision accountable, and the appointment is +# what makes the signature mean anything. +function test_12() { + + echo "===========12: AML decision by an unappointed officer===========" + echo -n "Modifying database: " +# shellcheck disable=SC2028 + echo "INSERT INTO exchange.aml_history + (h_payto, justification, decider_pub, decider_sig, outcome_serial_id) + SELECT h_payto, + 'injected by test 12', + '\x3333333333333333333333333333333333333333333333333333333333333333', + '\x44444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444', + outcome_serial_id + FROM exchange.legitimization_outcomes + ORDER BY outcome_serial_id + LIMIT 1;" \ + | psql -Aqt "$DB" + echo "DONE" + + run_audit + check_auditor_running + + echo -n "Checking that the bad signature was flagged... " + check_report_any \ + "row-inconsistency" \ + "diagnostic" "invalid officer signature on decision" + echo -n "Checking that the missing appointment was flagged... " + check_report_any \ + "row-inconsistency" \ + "diagnostic" "officer was not appointed when the decision was made" + echo -n "Checking that it was blamed on the right table... " + check_report_any \ + "row-inconsistency" \ + "row_table" "aml_history" + + full_reload + cleanup +} + + # *************** Main test loop starts here ************** diff --git a/src/auditor/test-revocation.sh b/src/auditor/test-revocation.sh @@ -157,7 +157,7 @@ function audit_only () { # Each helper is run twice: the first run audits from scratch, the # second one exercises the incremental path. Findings end up in the # auditor database and are inspected via the auditor's REST API. - for helper in aggregation coins deposits reserves \ + for helper in aggregation aml coins deposits reserves \ wire-credit wire-debit purses transfer do for pass in "" "-inc" diff --git a/src/auditordb/0003-auditor_aml_staff.sql b/src/auditordb/0003-auditor_aml_staff.sql @@ -0,0 +1,40 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2014--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/> +-- + +SET search_path TO auditor; +CREATE TABLE IF NOT EXISTS auditor_aml_staff +( + row_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE PRIMARY KEY, + decider_pub BYTEA NOT NULL CHECK (LENGTH(decider_pub)=32), + decider_name TEXT NOT NULL, + is_active BOOLEAN NOT NULL, + read_only BOOLEAN NOT NULL, + master_sig_valid BOOLEAN NOT NULL, + last_change INT8 NOT NULL, + UNIQUE (decider_pub, last_change) +); +COMMENT ON TABLE auditor_aml_staff + IS 'Status changes of the exchange''s AML staff, as observed by the auditor. The exchange''s own aml_staff is append-only and holds the same history, but only as a promise its code makes; this is the auditor''s independent record of who was allowed to decide when, and of what it had already seen when.'; +COMMENT ON COLUMN auditor_aml_staff.decider_pub + IS 'Public key of the AML staff member'; +COMMENT ON COLUMN auditor_aml_staff.is_active + IS 'true if the staff member was allowed to act from last_change onwards'; +COMMENT ON COLUMN auditor_aml_staff.read_only + IS 'true if the staff member had read-only access from last_change onwards, and thus could not legitimately sign decisions'; +COMMENT ON COLUMN auditor_aml_staff.master_sig_valid + IS 'true if the exchange''s offline master key really did sign this status. A status that fails this check confers no authority, but is still recorded so that the finding is raised once instead of on every round.'; +COMMENT ON COLUMN auditor_aml_staff.last_change + IS 'Time from which this status was in force, as claimed by the exchange. The status holds until the next change for the same decider_pub.'; diff --git a/src/auditordb/insert_aml_staff.c b/src/auditordb/insert_aml_staff.c @@ -0,0 +1,60 @@ +/* + 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/auditordb/insert_aml_staff.c + * @brief Implementation of the insert_aml_staff function for Postgres + * @author Christian Grothoff + */ +#include "taler/taler_pq_lib.h" +#include "pg_helper.h" +#include "auditor-database/insert_aml_staff.h" + + +enum GNUNET_DB_QueryStatus +TALER_AUDITORDB_insert_aml_staff ( + struct TALER_AUDITORDB_PostgresContext *pg, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const char *decider_name, + bool is_active, + bool read_only, + bool master_sig_valid, + struct GNUNET_TIME_Timestamp last_change) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (decider_pub), + GNUNET_PQ_query_param_string (decider_name), + GNUNET_PQ_query_param_bool (is_active), + GNUNET_PQ_query_param_bool (read_only), + GNUNET_PQ_query_param_bool (master_sig_valid), + GNUNET_PQ_query_param_timestamp (&last_change), + GNUNET_PQ_query_param_end + }; + + PREPARE (pg, + "insert_aml_staff", + "INSERT INTO auditor_aml_staff " + "(decider_pub," + " decider_name," + " is_active," + " read_only," + " master_sig_valid," + " last_change" + ") VALUES ($1,$2,$3,$4,$5,$6)" + " ON CONFLICT (decider_pub, last_change) DO NOTHING;"); + return GNUNET_PQ_eval_prepared_non_select (pg->conn, + "insert_aml_staff", + params); +} diff --git a/src/auditordb/iterate_aml_staff.c b/src/auditordb/iterate_aml_staff.c @@ -0,0 +1,149 @@ +/* + 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/auditordb/iterate_aml_staff.c + * @brief Implementation of the iterate_aml_staff function for Postgres + * @author Christian Grothoff + */ +#include "taler/taler_error_codes.h" +#include "taler/taler_pq_lib.h" +#include "pg_helper.h" +#include "auditor-database/iterate_aml_staff.h" + + +/** + * Closure for #aml_staff_cb(). + */ +struct AmlStaffContext +{ + /** + * Function to call for each status change. + */ + TALER_AUDITORDB_AmlStaffCallback cb; + + /** + * Closure for @e cb. + */ + void *cb_cls; + + /** + * Query status to return. + */ + enum GNUNET_DB_QueryStatus qs; +}; + + +/** + * Helper function for #TALER_AUDITORDB_iterate_aml_staff(). + * To be called with the results of a SELECT statement + * that has returned @a num_results results. + * + * @param cls closure of type `struct AmlStaffContext *` + * @param result the postgres result + * @param num_results the number of results in @a result + */ +static void +aml_staff_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct AmlStaffContext *asc = cls; + + for (unsigned int i = 0; i < num_results; i++) + { + struct TALER_AmlOfficerPublicKeyP decider_pub; + char *decider_name = NULL; + bool is_active; + bool read_only; + bool master_sig_valid; + struct GNUNET_TIME_Timestamp last_change; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_auto_from_type ("decider_pub", + &decider_pub), + GNUNET_PQ_result_spec_string ("decider_name", + &decider_name), + GNUNET_PQ_result_spec_bool ("is_active", + &is_active), + GNUNET_PQ_result_spec_bool ("read_only", + &read_only), + GNUNET_PQ_result_spec_bool ("master_sig_valid", + &master_sig_valid), + GNUNET_PQ_result_spec_timestamp ("last_change", + &last_change), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_GenericReturnValue rval; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + asc->qs = GNUNET_DB_STATUS_HARD_ERROR; + return; + } + asc->qs = i + 1; + rval = asc->cb (asc->cb_cls, + &decider_pub, + decider_name, + is_active, + read_only, + master_sig_valid, + last_change); + GNUNET_PQ_cleanup_result (rs); + if (GNUNET_OK != rval) + break; + } +} + + +enum GNUNET_DB_QueryStatus +TALER_AUDITORDB_iterate_aml_staff ( + struct TALER_AUDITORDB_PostgresContext *pg, + TALER_AUDITORDB_AmlStaffCallback cb, + TALER_AUDITORDB_AML_STAFF_RESULT_CLOSURE *cb_cls) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_end + }; + struct AmlStaffContext asc = { + .cb = cb, + .cb_cls = cb_cls + }; + enum GNUNET_DB_QueryStatus qs; + + PREPARE (pg, + "iterate_aml_staff", + "SELECT" + " decider_pub" + ",decider_name" + ",is_active" + ",read_only" + ",master_sig_valid" + ",last_change" + " FROM auditor_aml_staff" + " ORDER BY last_change ASC, row_id ASC;"); + qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn, + "iterate_aml_staff", + params, + &aml_staff_cb, + &asc); + if (qs > 0) + return asc.qs; + GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR != qs); + return qs; +} diff --git a/src/auditordb/meson.build b/src/auditordb/meson.build @@ -52,6 +52,7 @@ auditor_0002_sql = [ auditor_0003_sql = [ '0003-preamble.sql', + '0003-auditor_aml_staff.sql', '0003-auditor_kycauth_in_inconsistency.sql', 'commit.sql', ] @@ -96,6 +97,7 @@ libtalerauditordb = library( 'event_listen.c', 'event_notify.c', 'gc.c', + 'iterate_aml_staff.c', 'iterate_amount_arithmetic_inconsistencies.c', 'iterate_auditor_closure_lags.c', 'get_auditor_progress.c', @@ -131,6 +133,7 @@ libtalerauditordb = library( 'iterate_wire_format_inconsistencies.c', 'iterate_wire_out_inconsistencies.c', 'helper.c', + 'insert_aml_staff.c', 'insert_amount_arithmetic_inconsistency.c', 'insert_auditor_closure_lags.c', 'insert_auditor_progress.c', diff --git a/src/auditordb/restart.sql b/src/auditordb/restart.sql @@ -31,6 +31,7 @@ SET search_path TO auditor; -- Unlike the other SQL files, it SHOULD be updated to reflect the -- latest requirements for dropping tables. +DELETE FROM auditor_aml_staff; DELETE FROM auditor_amount_arithmetic_inconsistency; DELETE FROM auditor_bad_sig_losses; DELETE FROM auditor_balances; diff --git a/src/exchangedb/get_aml_officer.c b/src/exchangedb/get_aml_officer.c @@ -50,6 +50,8 @@ TALER_EXCHANGEDB_get_aml_officer ( GNUNET_PQ_result_spec_end }; + /* aml_staff is append-only, one row per status change; the status in + force is the row with the greatest last_change. */ PREPARE (pg, "get_aml_officer", "SELECT " @@ -59,7 +61,9 @@ TALER_EXCHANGEDB_get_aml_officer ( ",read_only" ",last_change" " FROM aml_staff" - " WHERE decider_pub=$1;"); + " WHERE decider_pub=$1" + " ORDER BY last_change DESC" + " LIMIT 1;"); return GNUNET_PQ_eval_prepared_singleton_select ( pg->conn, "get_aml_officer", diff --git a/src/exchangedb/get_exists_aml_officer.c b/src/exchangedb/get_exists_aml_officer.c @@ -39,12 +39,20 @@ TALER_EXCHANGEDB_get_exists_aml_officer ( GNUNET_PQ_result_spec_end }; + /* aml_staff is append-only, so the officer's status is the row with the + greatest last_change. The is_active filter has to be applied to that + row and only to it: filtering first would let any active status the + officer ever held answer for one that has since been revoked. */ PREPARE (pg, "get_exists_aml_officer", "SELECT read_only" - " FROM aml_staff" - " WHERE decider_pub=$1" - " AND is_active;"); + " FROM (SELECT is_active" + " ,read_only" + " FROM aml_staff" + " WHERE decider_pub=$1" + " ORDER BY last_change DESC" + " LIMIT 1) latest" + " WHERE is_active;"); return GNUNET_PQ_eval_prepared_singleton_select (pg->conn, "get_exists_aml_officer", params, diff --git a/src/exchangedb/insert_aml_decision.sql b/src/exchangedb/insert_aml_decision.sql @@ -61,11 +61,18 @@ THEN THEN RAISE EXCEPTION 'Got in_decider_sig without justification or signature.'; END IF; - -- Check officer is eligible to make decisions. + -- Check officer is eligible to make decisions. aml_staff is append-only, + -- so this must look at the officer's *latest* status and only then at the + -- flags: filtering on the flags first would let any read-write status the + -- officer ever held answer for one that has since been revoked. PERFORM - FROM aml_staff - WHERE decider_pub=in_decider_pub - AND is_active + FROM (SELECT is_active + ,read_only + FROM aml_staff + WHERE decider_pub=in_decider_pub + ORDER BY last_change DESC + LIMIT 1) latest + WHERE is_active AND NOT read_only; IF NOT FOUND THEN diff --git a/src/exchangedb/insert_aml_officer.sql b/src/exchangedb/insert_aml_officer.sql @@ -25,6 +25,31 @@ CREATE OR REPLACE FUNCTION exchange_do_insert_aml_officer( LANGUAGE plpgsql AS $$ BEGIN +-- aml_staff is append-only: one row per status change, the status in force +-- being the row with the greatest last_change. Never UPDATE a row here. +-- The history is what lets an auditor say who was allowed to decide when, +-- and an in-place update leaves aml_staff_uuid where it was, so it is +-- invisible to every cursor that walks the table by its serial ID -- +-- taler-auditor-sync would never carry the change to the auditor at all. + +-- Check the new status is more recent than the one in force... +SELECT last_change + INTO out_last_change + FROM exchange.aml_staff + WHERE decider_pub=in_decider_pub + ORDER BY last_change DESC + LIMIT 1; + +IF NOT FOUND +THEN + out_last_change=0; +ELSIF out_last_change >= in_last_change +THEN + -- Refuse to insert an older status; leave out_last_change at the + -- last_change we do have, so the caller can tell it was refused. + RETURN; +END IF; + INSERT INTO exchange.aml_staff (decider_pub ,master_sig @@ -39,36 +64,11 @@ INSERT INTO exchange.aml_staff ,in_is_active ,in_read_only ,in_last_change) - ON CONFLICT DO NOTHING; -IF FOUND -THEN - out_last_change=0; - RETURN; -END IF; - --- Check update is most recent... -SELECT last_change - INTO out_last_change - FROM exchange.aml_staff - WHERE decider_pub=in_decider_pub; -ASSERT FOUND, 'cannot have INSERT conflict but no AML staff record'; - -IF out_last_change >= in_last_change -THEN - -- Refuse to insert older status - RETURN; -END IF; - --- We are more recent, update existing record. -UPDATE exchange.aml_staff - SET master_sig=in_master_sig - ,decider_name=in_decider_name - ,is_active=in_is_active - ,read_only=in_read_only - ,last_change=in_last_change - WHERE decider_pub=in_decider_pub; + -- A concurrent transaction may have appended the very same status + -- between the SELECT above and here; that is a replay, not an error. + ON CONFLICT (decider_pub, last_change) DO NOTHING; END $$; COMMENT ON FUNCTION exchange_do_insert_aml_officer(BYTEA, BYTEA, TEXT, BOOL, BOOL, INT8) - IS 'Inserts or updates AML staff record, making sure the update is more recent than the previous change'; + IS 'Appends an AML staff status change, making sure it is more recent than the status currently in force'; diff --git a/src/exchangedb/iterate_aml_attributes.c b/src/exchangedb/iterate_aml_attributes.c @@ -140,6 +140,10 @@ TALER_EXCHANGEDB_iterate_aml_attributes ( ? "iterate_aml_attributes_inc" : "iterate_aml_attributes_dec"; + /* aml_staff is append-only, so it holds one row per status change of an + officer. Joining it plainly would return the attributes once per + status the officer ever had; the LATERAL picks the officer's latest + name instead. */ PREPARE (pg, "iterate_aml_attributes_inc", "SELECT" @@ -152,9 +156,13 @@ TALER_EXCHANGEDB_iterate_aml_attributes ( " LEFT JOIN legitimization_processes lp" " ON (ka.by_aml_officer AND" " (ka.legitimization_serial = lp.legitimization_process_serial_id))" - " LEFT JOIN aml_staff astaff" - " ON (ka.by_aml_officer AND" - " (DECODE(lp.provider_user_id, 'base64') = astaff.decider_pub))" + " LEFT JOIN LATERAL" + " (SELECT s.decider_name" + " FROM aml_staff s" + " WHERE ka.by_aml_officer" + " AND (DECODE(lp.provider_user_id, 'base64') = s.decider_pub)" + " ORDER BY s.last_change DESC" + " LIMIT 1) astaff ON TRUE" " WHERE ka.h_payto=$1" " AND ka.kyc_attributes_serial_id > $2" " ORDER BY ka.kyc_attributes_serial_id ASC" @@ -171,9 +179,13 @@ TALER_EXCHANGEDB_iterate_aml_attributes ( " LEFT JOIN legitimization_processes lp" " ON (ka.by_aml_officer AND" " (ka.legitimization_serial = lp.legitimization_process_serial_id))" - " LEFT JOIN aml_staff astaff" - " ON (ka.by_aml_officer AND" - " (DECODE(lp.provider_user_id, 'base64') = astaff.decider_pub))" + " LEFT JOIN LATERAL" + " (SELECT s.decider_name" + " FROM aml_staff s" + " WHERE ka.by_aml_officer" + " AND (DECODE(lp.provider_user_id, 'base64') = s.decider_pub)" + " ORDER BY s.last_change DESC" + " LIMIT 1) astaff ON TRUE" " WHERE ka.h_payto=$1" " AND ka.kyc_attributes_serial_id < $2" " ORDER BY ka.kyc_attributes_serial_id DESC" diff --git a/src/exchangedb/iterate_aml_history_above_serial_id.c b/src/exchangedb/iterate_aml_history_above_serial_id.c @@ -0,0 +1,219 @@ +/* + 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_aml_history_above_serial_id.c + * @brief Implementation of the iterate_aml_history_above_serial_id function for Postgres + * @author Christian Grothoff + */ +#include "taler/taler_pq_lib.h" +#include "helper.h" +#include "exchange-database/iterate_aml_history_above_serial_id.h" + + +/** + * Closure for #aml_decision_cb(). + */ +struct AmlDecisionContext +{ + /** + * Function to call for each decision. + */ + TALER_EXCHANGEDB_AmlDecisionCallback cb; + + /** + * Closure for @e cb. + */ + void *cb_cls; + + /** + * Query status to return. + */ + enum GNUNET_DB_QueryStatus qs; +}; + + +/** + * Helper function for + * #TALER_EXCHANGEDB_iterate_aml_history_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 AmlDecisionContext *` + * @param result the postgres result + * @param num_results the number of results in @a result + */ +static void +aml_decision_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct AmlDecisionContext *adc = cls; + + for (unsigned int i = 0; i < num_results; i++) + { + uint64_t rowid; + struct TALER_NormalizedPaytoHashP h_payto; + char *justification = NULL; + struct TALER_AmlOfficerPublicKeyP decider_pub; + struct TALER_AmlOfficerSignatureP decider_sig; + struct GNUNET_TIME_Timestamp decision_time; + json_t *jproperties = NULL; + json_t *jnew_rules = NULL; + char *new_measure_name = NULL; + bool to_investigate; + struct GNUNET_TIME_Timestamp attributes_expiration + = GNUNET_TIME_UNIT_ZERO_TS; + struct GNUNET_HashCode h_attributes; + bool no_decider_pub; + bool no_decider_sig; + bool no_new_rules; + bool no_measure; + bool no_attributes_expiration; + bool no_h_attributes; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("aml_history_serial_id", + &rowid), + GNUNET_PQ_result_spec_auto_from_type ("h_payto", + &h_payto), + GNUNET_PQ_result_spec_string ("justification", + &justification), + /* decider_pub, decider_sig, kyc_attributes_hash and jnew_rules all + lack a NOT NULL constraint. An honest decision has all of them; + reporting the ones that do not is the auditor's job, so they must + not abort the query here. */ + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_auto_from_type ("decider_pub", + &decider_pub), + &no_decider_pub), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_auto_from_type ("decider_sig", + &decider_sig), + &no_decider_sig), + GNUNET_PQ_result_spec_timestamp ("decision_time", + &decision_time), + GNUNET_PQ_result_spec_allow_null ( + TALER_PQ_result_spec_json ("jproperties", + &jproperties), + NULL), + GNUNET_PQ_result_spec_allow_null ( + TALER_PQ_result_spec_json ("jnew_rules", + &jnew_rules), + &no_new_rules), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_string ("new_measure_name", + &new_measure_name), + &no_measure), + GNUNET_PQ_result_spec_bool ("to_investigate", + &to_investigate), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_timestamp ("attributes_expiration", + &attributes_expiration), + &no_attributes_expiration), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_auto_from_type ("kyc_attributes_hash", + &h_attributes), + &no_h_attributes), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_GenericReturnValue rval; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + adc->qs = GNUNET_DB_STATUS_HARD_ERROR; + return; + } + if (no_attributes_expiration) + attributes_expiration = GNUNET_TIME_UNIT_ZERO_TS; + adc->qs = i + 1; + rval = adc->cb (adc->cb_cls, + rowid, + &h_payto, + justification, + no_decider_pub ? NULL : &decider_pub, + no_decider_sig ? NULL : &decider_sig, + decision_time, + jproperties, + no_new_rules ? NULL : jnew_rules, + no_measure ? NULL : new_measure_name, + to_investigate, + attributes_expiration, + no_h_attributes ? NULL : &h_attributes); + GNUNET_PQ_cleanup_result (rs); + if (GNUNET_OK != rval) + break; + } +} + + +enum GNUNET_DB_QueryStatus +TALER_EXCHANGEDB_iterate_aml_history_above_serial_id ( + struct TALER_EXCHANGEDB_PostgresContext *pg, + uint64_t serial_id, + TALER_EXCHANGEDB_AmlDecisionCallback cb, + TALER_EXCHANGEDB_AML_DECISION_RESULT_CLOSURE *cb_cls) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_uint64 (&serial_id), + GNUNET_PQ_query_param_end + }; + struct AmlDecisionContext adc = { + .cb = cb, + .cb_cls = cb_cls + }; + enum GNUNET_DB_QueryStatus qs; + + /* legitimization_outcomes is joined on a NOT NULL column with a foreign + key, so an inner join here cannot silently drop a decision. The + attributes are optional, hence the LEFT JOIN for their expiration. */ + PREPARE (pg, + "iterate_aml_history_above_serial_id", + "SELECT" + " ah.aml_history_serial_id" + ",ah.h_payto" + ",ah.justification" + ",ah.decider_pub" + ",ah.decider_sig" + ",ah.kyc_attributes_hash" + ",lo.decision_time" + /* JSONB comes back with a binary version prefix, so it has to + be cast; the cast is what TALER_json_hash() then re-reads. */ + ",lo.jproperties::TEXT" + ",lo.jnew_rules::TEXT" + ",lo.new_measure_name" + ",lo.to_investigate" + ",ka.expiration_time AS attributes_expiration" + " FROM aml_history ah" + " JOIN legitimization_outcomes lo" + " USING (outcome_serial_id)" + " LEFT JOIN kyc_attributes ka" + " ON (ah.kyc_attributes_serial_id = ka.kyc_attributes_serial_id)" + " WHERE ah.aml_history_serial_id>=$1" + " ORDER BY ah.aml_history_serial_id ASC;"); + qs = GNUNET_PQ_eval_prepared_multi_select ( + pg->conn, + "iterate_aml_history_above_serial_id", + params, + &aml_decision_cb, + &adc); + if (qs > 0) + return adc.qs; + GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR != qs); + return qs; +} diff --git a/src/exchangedb/iterate_aml_staff_above_serial_id.c b/src/exchangedb/iterate_aml_staff_above_serial_id.c @@ -0,0 +1,165 @@ +/* + 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_aml_staff_above_serial_id.c + * @brief Implementation of the iterate_aml_staff_above_serial_id function for Postgres + * @author Christian Grothoff + */ +#include "taler/taler_pq_lib.h" +#include "helper.h" +#include "exchange-database/iterate_aml_staff_above_serial_id.h" + + +/** + * Closure for #aml_staff_cb(). + */ +struct AmlStaffContext +{ + /** + * Function to call for each status change. + */ + TALER_EXCHANGEDB_AmlStaffCallback cb; + + /** + * Closure for @e cb. + */ + void *cb_cls; + + /** + * Query status to return. + */ + enum GNUNET_DB_QueryStatus qs; +}; + + +/** + * Helper function for + * #TALER_EXCHANGEDB_iterate_aml_staff_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 AmlStaffContext *` + * @param result the postgres result + * @param num_results the number of results in @a result + */ +static void +aml_staff_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct AmlStaffContext *asc = cls; + + for (unsigned int i = 0; i < num_results; i++) + { + uint64_t rowid; + struct TALER_AmlOfficerPublicKeyP decider_pub; + struct TALER_MasterSignatureP master_sig; + char *decider_name = NULL; + bool is_active; + bool read_only; + struct GNUNET_TIME_Timestamp last_change; + bool no_master_sig; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("aml_staff_uuid", + &rowid), + GNUNET_PQ_result_spec_auto_from_type ("decider_pub", + &decider_pub), + /* the column has no NOT NULL constraint, and a row without a + signature is exactly what the auditor has to complain about */ + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_auto_from_type ("master_sig", + &master_sig), + &no_master_sig), + GNUNET_PQ_result_spec_string ("decider_name", + &decider_name), + GNUNET_PQ_result_spec_bool ("is_active", + &is_active), + GNUNET_PQ_result_spec_bool ("read_only", + &read_only), + GNUNET_PQ_result_spec_timestamp ("last_change", + &last_change), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_GenericReturnValue rval; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + asc->qs = GNUNET_DB_STATUS_HARD_ERROR; + return; + } + asc->qs = i + 1; + rval = asc->cb (asc->cb_cls, + rowid, + &decider_pub, + no_master_sig ? NULL : &master_sig, + decider_name, + is_active, + read_only, + last_change); + GNUNET_PQ_cleanup_result (rs); + if (GNUNET_OK != rval) + break; + } +} + + +enum GNUNET_DB_QueryStatus +TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id ( + struct TALER_EXCHANGEDB_PostgresContext *pg, + uint64_t serial_id, + TALER_EXCHANGEDB_AmlStaffCallback cb, + TALER_EXCHANGEDB_AML_STAFF_RESULT_CLOSURE *cb_cls) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_uint64 (&serial_id), + GNUNET_PQ_query_param_end + }; + struct AmlStaffContext asc = { + .cb = cb, + .cb_cls = cb_cls + }; + enum GNUNET_DB_QueryStatus qs; + + /* Ordered by the serial ID and not by last_change: the caller is told + about the changes in the order the exchange recorded them, which is + what makes a status backdated after the fact stand out. */ + PREPARE (pg, + "iterate_aml_staff_above_serial_id", + "SELECT" + " aml_staff_uuid" + ",decider_pub" + ",master_sig" + ",decider_name" + ",is_active" + ",read_only" + ",last_change" + " FROM aml_staff" + " WHERE aml_staff_uuid>=$1" + " ORDER BY aml_staff_uuid ASC;"); + qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn, + "iterate_aml_staff_above_serial_id", + params, + &aml_staff_cb, + &asc); + if (qs > 0) + return asc.qs; + GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR != qs); + return qs; +} diff --git a/src/exchangedb/meson.build b/src/exchangedb/meson.build @@ -154,6 +154,8 @@ libtalerexchangedb = library( 'get_active_legitimization.c', 'get_aml_file_number.c', 'iterate_aml_history.c', + 'iterate_aml_history_above_serial_id.c', + 'iterate_aml_staff_above_serial_id.c', 'get_aml_officer.c', 'get_auditor_status.c', 'get_auditor_timestamp.c', diff --git a/src/exchangedb/sql-schema/0011-aml_staff.sql b/src/exchangedb/sql-schema/0011-aml_staff.sql @@ -0,0 +1,51 @@ +-- +-- 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/> +-- + +-- Make aml_staff append-only. +-- +-- The table used to hold exactly one row per officer, keyed by decider_pub +-- and UPSERTed in place on every status change. Two things followed from +-- that, both bad. The officer's history was unrecoverable, so nobody -- +-- least of all an auditor -- could answer "was this officer allowed to +-- decide at the time of that decision?". And because an update left +-- aml_staff_uuid where it was, a status change was invisible to every +-- cursor that walks a table by its serial ID: taler-auditor-sync appends +-- only, so it replicated an officer's first appearance and then silently +-- never carried a single change of it to the auditor's replica. +-- +-- From here on the table records one row per status change and the current +-- status of an officer is the row with the greatest last_change. + +ALTER TABLE aml_staff + DROP CONSTRAINT aml_staff_pkey; + +-- Redundant once aml_staff_uuid becomes the primary key below. +ALTER TABLE aml_staff + DROP CONSTRAINT aml_staff_aml_staff_uuid_key; + +ALTER TABLE aml_staff + ADD PRIMARY KEY (aml_staff_uuid); + +-- Serves the "most recent status of this officer" lookups (by backward +-- scan), and keeps the exchange from recording the same status twice. +CREATE UNIQUE INDEX aml_staff_by_decider_pub_last_change + ON aml_staff + (decider_pub, last_change); + +COMMENT ON TABLE aml_staff + IS 'Status changes of the AML staff members the exchange uses or has used in the past, one row per change. Append-only: the current status of a member is the row with the greatest last_change. Entries never expire as we need to remember the history indefinitely.'; +COMMENT ON COLUMN aml_staff.last_change + IS 'Time from which this status was in force, until superseded by a row with a greater last_change for the same decider_pub. Used to detect replays of old messages.'; diff --git a/src/exchangedb/sql-schema/0011-preamble.sql b/src/exchangedb/sql-schema/0011-preamble.sql @@ -0,0 +1,20 @@ +-- +-- 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/> +-- + +BEGIN; + +SELECT _v.register_patch('exchange-0011', NULL, NULL); +SET search_path TO exchange; diff --git a/src/exchangedb/sql-schema/auditor-triggers-0002.sql b/src/exchangedb/sql-schema/auditor-triggers-0002.sql @@ -0,0 +1,51 @@ +-- +-- 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/> +-- + +-- Everything in one big transaction +BEGIN; + +SELECT _v.register_patch('auditor-triggers-0002'); + +-- The AML helper listens to this trigger. The channel is the one +-- GNUNET_PQ_get_event_notify_channel() derives from a +-- TALER_DBEVENT_EXCHANGE_AUDITOR_WAKE_HELPER_AML (1236) event header. +CREATE OR REPLACE FUNCTION auditor_wake_aml_helper_trigger() + RETURNS trigger + LANGUAGE plpgsql +AS $$ +BEGIN + NOTIFY XYRBWY61ENX0AQVQ2M8X2MSZMTADQPK18VM1TAW7VWFFHD85CGXNG; +RETURN NEW; +END $$; +COMMENT ON FUNCTION auditor_wake_aml_helper_trigger() + IS 'Call auditor_call_db_notify on new entry'; + + +-- A status change of an AML officer; the helper checks the offline master +-- key's signature on it. aml_staff is append-only, so every change is an +-- INSERT and this fires for all of them. +CREATE OR REPLACE TRIGGER auditor_exchange_notify_helper_aml0 + AFTER INSERT ON exchange.aml_staff +EXECUTE FUNCTION auditor_wake_aml_helper_trigger(); + + +-- An AML decision; the helper checks the officer's signature on it and +-- that the officer was eligible to make it. +CREATE OR REPLACE TRIGGER auditor_exchange_notify_helper_aml1 + AFTER INSERT ON exchange.aml_history +EXECUTE FUNCTION auditor_wake_aml_helper_trigger(); + +COMMIT; diff --git a/src/exchangedb/sql-schema/meson.build b/src/exchangedb/sql-schema/meson.build @@ -159,8 +159,11 @@ exchange_0010_sql = [ 'commit.sql', ] +exchange_0011_sql = ['0011-preamble.sql', '0011-aml_staff.sql', 'commit.sql'] + generated_sql = [ ['auditor-triggers-0001.sql', ['auditor-triggers-0001.sql']], + ['auditor-triggers-0002.sql', ['auditor-triggers-0002.sql']], ['versioning.sql', ['versioning.sql']], ['drop.sql', ['drop.sql']], ['procedures.sql', procedures_sql], @@ -175,6 +178,7 @@ generated_sql = [ ['exchange-0008.sql', ['exchange-0008.sql']], ['exchange-0009.sql', exchange_0009_sql], ['exchange-0010.sql', exchange_0010_sql], + ['exchange-0011.sql', exchange_0011_sql], ['tops-0001.sql', ['tops-0001.sql']], ] diff --git a/src/include/auditor-database/insert_aml_staff.h b/src/include/auditor-database/insert_aml_staff.h @@ -0,0 +1,56 @@ +/* + 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/auditor-database/insert_aml_staff.h + * @brief implementation of the insert_aml_staff function for Postgres + * @author Christian Grothoff + */ +#ifndef AUDITOR_DATABASE_INSERT_AML_STAFF_H +#define AUDITOR_DATABASE_INSERT_AML_STAFF_H + +#include "auditordb_lib.h" + + +/** + * Remember an AML staff status change we observed at the exchange. + * + * Does nothing if we already recorded this (@a decider_pub, @a last_change) + * pair, which is the normal case: the exchange keeps showing us the current + * status of every staff member on every round. + * + * @param pg the database context + * @param decider_pub public key of the staff member + * @param decider_name legal name of the staff member + * @param is_active true if the staff member may act from @a last_change on + * @param read_only true if the staff member has read-only access + * @param master_sig_valid true if the exchange's offline master key really + * did sign this status; a status that fails the check confers no + * authority but is still recorded, so that the finding is raised once + * rather than on every round + * @param last_change when the status took effect, as claimed by the exchange + * @return query result status + */ +enum GNUNET_DB_QueryStatus +TALER_AUDITORDB_insert_aml_staff ( + struct TALER_AUDITORDB_PostgresContext *pg, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const char *decider_name, + bool is_active, + bool read_only, + bool master_sig_valid, + struct GNUNET_TIME_Timestamp last_change); + +#endif diff --git a/src/include/auditor-database/iterate_aml_staff.h b/src/include/auditor-database/iterate_aml_staff.h @@ -0,0 +1,76 @@ +/* + 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/auditor-database/iterate_aml_staff.h + * @brief implementation of the iterate_aml_staff function for Postgres + * @author Christian Grothoff + */ +#ifndef AUDITOR_DATABASE_ITERATE_AML_STAFF_H +#define AUDITOR_DATABASE_ITERATE_AML_STAFF_H + +#include "taler/taler_util.h" +#include "auditordb_lib.h" + + +#ifndef TALER_AUDITORDB_AML_STAFF_RESULT_CLOSURE +/** + * Type of the closure for #TALER_AUDITORDB_AmlStaffCallback. + */ +#define TALER_AUDITORDB_AML_STAFF_RESULT_CLOSURE void +#endif + +/* Callback typedefs */ +/** + * Function called with an AML staff status change the auditor recorded + * earlier. + * + * @param cls closure + * @param decider_pub public key of the staff member + * @param decider_name legal name of the staff member + * @param is_active true if the staff member could act from @a last_change on + * @param read_only true if the staff member had read-only access + * @param master_sig_valid true if the exchange's offline master key really + * did sign this status + * @param last_change when the status took effect + * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop + */ +typedef enum GNUNET_GenericReturnValue +(*TALER_AUDITORDB_AmlStaffCallback)( + TALER_AUDITORDB_AML_STAFF_RESULT_CLOSURE *cls, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const char *decider_name, + bool is_active, + bool read_only, + bool master_sig_valid, + struct GNUNET_TIME_Timestamp last_change); + + +/** + * Get all AML staff status changes the auditor has recorded, oldest first, + * so that the caller can reconstruct who was allowed to decide when. + * + * @param pg the database context + * @param cb function to call with results + * @param cb_cls closure for @a cb + * @return query result status + */ +enum GNUNET_DB_QueryStatus +TALER_AUDITORDB_iterate_aml_staff ( + struct TALER_AUDITORDB_PostgresContext *pg, + TALER_AUDITORDB_AmlStaffCallback cb, + TALER_AUDITORDB_AML_STAFF_RESULT_CLOSURE *cb_cls); + +#endif diff --git a/src/include/exchange-database/iterate_aml_history_above_serial_id.h b/src/include/exchange-database/iterate_aml_history_above_serial_id.h @@ -0,0 +1,97 @@ +/* + 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_aml_history_above_serial_id.h + * @brief implementation of the iterate_aml_history_above_serial_id function for Postgres + * @author Christian Grothoff + */ +#ifndef EXCHANGE_DATABASE_ITERATE_AML_HISTORY_ABOVE_SERIAL_ID_H +#define EXCHANGE_DATABASE_ITERATE_AML_HISTORY_ABOVE_SERIAL_ID_H + +#include "taler/taler_util.h" +#include "taler/taler_json_lib.h" +#include "exchangedb_lib.h" + + +#ifndef TALER_EXCHANGEDB_AML_DECISION_RESULT_CLOSURE +/** + * Type of the closure for #TALER_EXCHANGEDB_AmlDecisionCallback. + */ +#define TALER_EXCHANGEDB_AML_DECISION_RESULT_CLOSURE void +#endif + +/* Callback typedefs */ +/** + * Function called with an AML decision made by an AML officer, with + * everything the officer's signature covers. + * + * @param cls closure + * @param rowid unique serial ID of the entry in aml_history + * @param h_payto normalized payto hash of the account the decision is about + * @param justification human-readable justification given + * @param decider_pub public key of the officer who decided, NULL if the + * exchange did not record one + * @param decider_sig the officer's signature, NULL if the exchange did not + * record one + * @param decision_time when the decision was taken, as claimed + * @param jproperties new account properties, NULL for none + * @param jnew_rules new KYC rules for the account, NULL if the exchange did + * not record any (which no honest decision lacks) + * @param new_measure_name measure to apply immediately, NULL for none + * @param to_investigate whether AML staff should investigate the account + * @param attributes_expiration when the attributes set with the decision + * expire, zero if the decision set no attributes + * @param h_attributes hash of the attributes set with the decision, NULL if + * the decision set none. The attributes themselves are stored only + * encrypted, so this hash is all an auditor can check against. + * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop + */ +typedef enum GNUNET_GenericReturnValue +(*TALER_EXCHANGEDB_AmlDecisionCallback)( + TALER_EXCHANGEDB_AML_DECISION_RESULT_CLOSURE *cls, + uint64_t rowid, + const struct TALER_NormalizedPaytoHashP *h_payto, + const char *justification, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const struct TALER_AmlOfficerSignatureP *decider_sig, + struct GNUNET_TIME_Timestamp decision_time, + const json_t *jproperties, + const json_t *jnew_rules, + const char *new_measure_name, + bool to_investigate, + struct GNUNET_TIME_Timestamp attributes_expiration, + const struct GNUNET_HashCode *h_attributes); + + +/** + * Select AML decisions from aml_history above @a serial_id in monotonically + * increasing order, joined with everything else the officer signed over. + * + * @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_aml_history_above_serial_id ( + struct TALER_EXCHANGEDB_PostgresContext *pg, + uint64_t serial_id, + TALER_EXCHANGEDB_AmlDecisionCallback cb, + TALER_EXCHANGEDB_AML_DECISION_RESULT_CLOSURE *cb_cls); + +#endif diff --git a/src/include/exchange-database/iterate_aml_staff_above_serial_id.h b/src/include/exchange-database/iterate_aml_staff_above_serial_id.h @@ -0,0 +1,84 @@ +/* + 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_aml_staff_above_serial_id.h + * @brief implementation of the iterate_aml_staff_above_serial_id function for Postgres + * @author Christian Grothoff + */ +#ifndef EXCHANGE_DATABASE_ITERATE_AML_STAFF_ABOVE_SERIAL_ID_H +#define EXCHANGE_DATABASE_ITERATE_AML_STAFF_ABOVE_SERIAL_ID_H + +#include "taler/taler_util.h" +#include "taler/taler_json_lib.h" +#include "exchangedb_lib.h" + + +#ifndef TALER_EXCHANGEDB_AML_STAFF_RESULT_CLOSURE +/** + * Type of the closure for #TALER_EXCHANGEDB_AmlStaffCallback. + */ +#define TALER_EXCHANGEDB_AML_STAFF_RESULT_CLOSURE void +#endif + +/* Callback typedefs */ +/** + * Function called with a status change of an AML staff member. + * + * @param cls closure + * @param rowid unique serial ID of the entry in aml_staff + * @param decider_pub public key of the staff member + * @param master_sig signature by the exchange's offline master key + * affirming this status, of purpose #TALER_SIGNATURE_MASTER_AML_KEY + * @param decider_name legal name of the staff member + * @param is_active true if the staff member could act from @a last_change on + * @param read_only true if the staff member had read-only access + * @param last_change when the status took effect, as claimed by the exchange + * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop + */ +typedef enum GNUNET_GenericReturnValue +(*TALER_EXCHANGEDB_AmlStaffCallback)( + TALER_EXCHANGEDB_AML_STAFF_RESULT_CLOSURE *cls, + uint64_t rowid, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const struct TALER_MasterSignatureP *master_sig, + const char *decider_name, + bool is_active, + bool read_only, + struct GNUNET_TIME_Timestamp last_change); + + +/** + * Select AML staff status changes with a serial ID of at least @a serial_id, + * in the order in which the exchange recorded them. + * + * `aml_staff` is append-only: a status change is a new row, so a caller can + * resume from where it stopped and will still see every change. The rows an + * officer accumulates are that officer's history, oldest first. + * + * @param pg the database context + * @param serial_id lowest serial ID to include + * @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_aml_staff_above_serial_id ( + struct TALER_EXCHANGEDB_PostgresContext *pg, + uint64_t serial_id, + TALER_EXCHANGEDB_AmlStaffCallback cb, + TALER_EXCHANGEDB_AML_STAFF_RESULT_CLOSURE *cb_cls); + +#endif diff --git a/src/include/taler/taler_crypto_lib.h b/src/include/taler/taler_crypto_lib.h @@ -3400,6 +3400,46 @@ TALER_officer_aml_decision_verify ( const json_t *attributes); +/** + * Verify AML decision, given only the hash of the attributes. + * + * Same as #TALER_officer_aml_decision_verify(), except that the attributes + * are passed pre-hashed. The auditor needs this: the exchange stores the + * attributes of an AML decision only in encrypted form, but records their + * hash in `aml_history.kyc_attributes_hash`, so the auditor can re-check the + * signature without ever seeing the personal data. + * + * @param justification human-readable justification + * @param decision_time when was the decision made + * @param h_payto payto URI hash of the account the + * decision is about + * @param new_rules new KYC rules to apply to the account + * @param properties properties of the account, can be NULL + * @param new_measures new measures to apply immediately, NULL for none + * @param to_investigate true if the account should be investigated by AML staff + * @param officer_pub public key of AML officer + * @param officer_sig signature to verify + * @param attributes_expiration expiration time of attributes, + * #GNUNET_TIME_UNIT_ZERO_ABS if no attributes given + * @param h_attributes hash over the attributes set by the AMLO, + * NULL if no attributes were set + * @return #GNUNET_OK if the signature is valid + */ +enum GNUNET_GenericReturnValue +TALER_officer_aml_decision_verify_hashed ( + const char *justification, + struct GNUNET_TIME_Timestamp decision_time, + const struct TALER_NormalizedPaytoHashP *h_payto, + const json_t *new_rules, + const json_t *properties, + const char *new_measures, + bool to_investigate, + const struct TALER_AmlOfficerPublicKeyP *officer_pub, + const struct TALER_AmlOfficerSignatureP *officer_sig, + struct GNUNET_TIME_Timestamp attributes_expiration, + const struct GNUNET_HashCode *h_attributes); + + /* **************** Helper-based RSA operations **************** */ /** diff --git a/src/include/taler/taler_dbevents.h b/src/include/taler/taler_dbevents.h @@ -444,6 +444,12 @@ enum TALER_DbEventType /** + * Event triggered when there is new data for the AML helper to process. + */ + TALER_DBEVENT_EXCHANGE_AUDITOR_WAKE_HELPER_AML = 1236, + + + /** * A wire transfer from the target account with the correct wire transfer subject was received. */ TALER_DBEVENT_ANASTASIS_AUTH_IBAN_TRANSFER = 2000, diff --git a/src/util/aml_signatures.c b/src/util/aml_signatures.c @@ -123,7 +123,7 @@ TALER_officer_aml_decision_sign ( enum GNUNET_GenericReturnValue -TALER_officer_aml_decision_verify ( +TALER_officer_aml_decision_verify_hashed ( const char *justification, struct GNUNET_TIME_Timestamp decision_time, const struct TALER_NormalizedPaytoHashP *h_payto, @@ -134,7 +134,7 @@ TALER_officer_aml_decision_verify ( const struct TALER_AmlOfficerPublicKeyP *officer_pub, const struct TALER_AmlOfficerSignatureP *officer_sig, struct GNUNET_TIME_Timestamp attributes_expiration, - const json_t *attributes) + const struct GNUNET_HashCode *h_attributes) { struct TALER_AmlDecisionPS ad = { .purpose.purpose = htonl (TALER_SIGNATURE_AML_DECISION), @@ -158,9 +158,8 @@ TALER_officer_aml_decision_verify ( GNUNET_CRYPTO_hash (new_measures, strlen (new_measures), &ad.h_new_measure); - if (NULL != attributes) - TALER_json_hash (attributes, - &ad.h_attributes); + if (NULL != h_attributes) + ad.h_attributes = *h_attributes; return GNUNET_CRYPTO_eddsa_verify ( TALER_SIGNATURE_AML_DECISION, &ad, @@ -169,6 +168,40 @@ TALER_officer_aml_decision_verify ( } +enum GNUNET_GenericReturnValue +TALER_officer_aml_decision_verify ( + const char *justification, + struct GNUNET_TIME_Timestamp decision_time, + const struct TALER_NormalizedPaytoHashP *h_payto, + const json_t *new_rules, + const json_t *properties, + const char *new_measures, + bool to_investigate, + const struct TALER_AmlOfficerPublicKeyP *officer_pub, + const struct TALER_AmlOfficerSignatureP *officer_sig, + struct GNUNET_TIME_Timestamp attributes_expiration, + const json_t *attributes) +{ + struct GNUNET_HashCode h_attributes; + + if (NULL != attributes) + TALER_json_hash (attributes, + &h_attributes); + return TALER_officer_aml_decision_verify_hashed ( + justification, + decision_time, + h_payto, + new_rules, + properties, + new_measures, + to_investigate, + officer_pub, + officer_sig, + attributes_expiration, + (NULL != attributes) ? &h_attributes : NULL); +} + + GNUNET_NETWORK_STRUCT_BEGIN /**