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