sync

Backup service to store encrypted wallet databases (experimental)
Log | Files | Refs | Submodules | README | LICENSE

commit a59f2302373b3ee83cf4d43773d04b5646e0c358
parent fc0aeb61c724488e58c4958305baed4c52168205
Author: Florian Dold <dold@taler.net>
Date:   Wed,  9 Sep 2026 01:17:41 +0200

implement invertible bloom filter

Diffstat:
Asrc/include/sync/sync-database/lookup_block_hashes_TR.h | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/sync/sync_database_lib.h | 1+
Asrc/include/sync/sync_ibf.h | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/sync/sync_service.h | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/sync/sync_testing_lib.h | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/meson.build | 1+
Asrc/lib/sync_api_block_reconcile.c | 337+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/sync/meson.build | 1+
Msrc/sync/sync-httpd2.c | 18++++++++++++------
Asrc/sync/sync-httpd2_get-backups-KEY-blocks-reconcile.c | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/sync/sync-httpd2_get-backups-KEY-blocks-reconcile.h | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/syncdb/meson.build | 3++-
Msrc/syncdb/procedures.sql.in | 1+
Asrc/syncdb/sync-0002.sql | 37+++++++++++++++++++++++++++++++++++++
Asrc/syncdb/syncdb_lookup_block_hashes_TR.c | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/syncdb/syncdb_lookup_block_hashes_TR.sql | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/syncdb/test_sync_db.c | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/meson.build | 1+
Msrc/testing/test_sync_api.c | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/testing/testing_api_cmd_block_reconcile.c | 515+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util/meson.build | 26+++++++++++++++++++++++++-
Msrc/util/sync_block.c | 10++++++++--
Asrc/util/sync_ibf.c | 544+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/util/test_sync_ibf.c | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
24 files changed, 3008 insertions(+), 10 deletions(-)

diff --git a/src/include/sync/sync-database/lookup_block_hashes_TR.h b/src/include/sync/sync-database/lookup_block_hashes_TR.h @@ -0,0 +1,61 @@ +/* + This file is part of GNU 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 Lesser 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.GPL. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file include/sync/sync-database/lookup_block_hashes_TR.h + * @brief lookup the hashes of all the blocks of an account + * @author Iván Ávalos + */ +#ifndef SYNC_DATABASE_LOOKUP_BLOCK_HASHES_TR_H +#define SYNC_DATABASE_LOOKUP_BLOCK_HASHES_TR_H + +#include "sync/sync_service.h" + + +/** + * Iterator called for every block returned by the query. + * + * @param cls closure pointer + * @param nonce nonce identifying the block + * @param block_hash hash of the block data + */ +typedef void +(*SYNC_DB_BlockHashIterator)(void *cls, + const struct SYNC_BlockNonce *nonce, + const struct GNUNET_HashCode *block_hash); + + +/** + * Lookup the identities of all the blocks in the account's linked + * list. The @a it iterator is called for every block, in arbitrary + * order. + * + * @param account_pub account to look the blocks up for + * @param it iterator to call for every block (may be NULL to only + * count the blocks) + * @param it_cls closure for @a it + * @return number of blocks found, + * #SYNC_DB_NO_RESULTS if the account has no blocks, + * #SYNC_DB_PAYMENT_REQUIRED if the account does not exist, + * #SYNC_DB_HARD_ERROR or #SYNC_DB_SOFT_ERROR on database + * errors + */ +enum SYNC_DB_QueryStatus +SYNCDB_lookup_block_hashes_TR ( + const struct SYNC_AccountPublicKeyP *account_pub, + SYNC_DB_BlockHashIterator it, + void *it_cls); + +#endif +\ No newline at end of file diff --git a/src/include/sync/sync_database_lib.h b/src/include/sync/sync_database_lib.h @@ -30,6 +30,7 @@ #include <sync/sync-database/update_block_TR.h> #include <sync/sync-database/increment_lifetime_TR.h> #include <sync/sync-database/lookup_block_range_TR.h> +#include <sync/sync-database/lookup_block_hashes_TR.h> #include <sync/sync-database/lookup_account_TR.h> #include <sync/sync-database/lookup_block_TR.h> #include <sync/sync-database/lookup_object.h> diff --git a/src/include/sync/sync_ibf.h b/src/include/sync/sync_ibf.h @@ -0,0 +1,211 @@ +/* + 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 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file include/sync/sync_ibf.h + * @brief invertible bloom filter for backup reconciliation (DD 92) + * @author Iván Ávalos + */ +#ifndef SYNC_IBF_H +#define SYNC_IBF_H + +#include <gnunet/gnunet_util_lib.h> + + +/** + * Number of hash functions used when the server computes the + * reconciliation filter of an account. + */ +#define SYNC_IBF_HASH_NUM 3 + +/** + * Minimum number of buckets a filter is built with. + */ +#define SYNC_IBF_MIN_BUCKETS 64 + +/** + * Maximum number of buckets a filter is built with (bounds the size + * of the serialized filter to + * #SYNC_IBF_MAX_BUCKETS * #SYNC_IBF_BUCKET_SIZE). + */ +#define SYNC_IBF_MAX_BUCKETS 4096 + +/** + * Size of one element: the 24-byte block nonce followed by the + * 64-byte block hash. + */ +#define SYNC_IBF_KEY_SIZE 88 + +/** + * Size of the key hash sum stored per bucket, in bytes. + */ +#define SYNC_IBF_KEY_HASH_SIZE 8 + +/** + * Size of one bucket: 2-byte counter + key sum + key hash sum. + */ +#define SYNC_IBF_BUCKET_SIZE (2 + SYNC_IBF_KEY_SIZE + SYNC_IBF_KEY_HASH_SIZE) + + +/** + * One element of the filter: the identity of a block. + */ +struct SYNC_IBFKey +{ + /** + * Nonce identifying the block. + */ + unsigned char nonce[24]; + + /** + * Hash of the block data. + */ + unsigned char hash[64]; +}; + + +/** + * Compute the number of buckets to use for a filter built over + * @a block_count blocks, sized so that small differences decode while + * the serialized filter stays bounded. + * + * @param block_count expected number of elements in the filter + * @return number of buckets to use (a power of two within + * [#SYNC_IBF_MIN_BUCKETS, #SYNC_IBF_MAX_BUCKETS]) + */ +uint32_t +SYNC_ibf_compute_bucket_count (uint32_t block_count); + + +/** + * Create a fresh invertible bloom filter. + * + * @param bucket_count number of buckets + * @param hash_num number of hash functions per element + * @param prefix mixing salt hashed with every element (4 bytes, + * network byte order, zero for the default) + * @return the filter, NULL on failure + */ +struct SYNC_IBF * +SYNC_ibf_create (uint32_t bucket_count, + unsigned int hash_num, + uint32_t prefix); + + +/** + * Create a filter from its serialized state. + * + * @param data serialized filter of @a bucket_count buckets + * (see #SYNC_ibf_serialize()) + * @param bucket_count number of buckets the serialized filter holds + * @param hash_num number of hash functions per element + * @param prefix mixing salt the serialized filter was built with + * @return the filter, NULL on failure + */ +struct SYNC_IBF * +SYNC_ibf_deserialize (const void *data, + uint32_t bucket_count, + unsigned int hash_num, + uint32_t prefix); + + +/** + * Number of bytes of the serialized filter. + * + * @param ibf filter to measure + * @return size in bytes, 0 if @a ibf is NULL + */ +size_t +SYNC_ibf_get_serialized_size (const struct SYNC_IBF *ibf); + + +/** + * Serialize the filter into @a data (network byte order). + * + * @param ibf filter to serialize + * @param data where to write the serialized filter + */ +void +SYNC_ibf_serialize (const struct SYNC_IBF *ibf, + void *data); + + +/** + * Free the filter. + * + * @param ibf filter to free, NULL is allowed + */ +void +SYNC_ibf_destroy (struct SYNC_IBF *ibf); + + +/** + * Insert an element into the filter. + * + * @param ibf filter to insert into + * @param key element to insert + */ +void +SYNC_ibf_insert (struct SYNC_IBF *ibf, + const struct SYNC_IBFKey *key); + + +/** + * Remove an element from the filter (must have been inserted). + * + * @param ibf filter to remove from + * @param key element to remove + */ +void +SYNC_ibf_remove (struct SYNC_IBF *ibf, + const struct SYNC_IBFKey *key); + + +/** + * Subtract @a ibf2 from @a ibf1, storing the result in @a ibf1. + * Both filters must have the same parameters. + * + * The result is the filter one would get from inserting exactly the + * elements that differ between the two original sets; decoding it + * enumerates them. + * + * @param ibf1 filter that is subtracted from + * @param ibf2 filter that is subtracted + */ +void +SYNC_ibf_subtract (struct SYNC_IBF *ibf1, + const struct SYNC_IBF *ibf2); + + +/** + * Decode and remove one element from the filter, peeling a pure + * bucket. + * + * @param ibf the filter to decode from + * @param[out] ret_side +1 if the element is only in ibf1 of a + * subtraction, -1 if it is only in ibf2 + * @param[out] ret_key where to store the decoded element + * @return #GNUNET_YES if an element was decoded, + * #GNUNET_NO if the filter is empty, + * #GNUNET_SYSERR if the filter cannot be decoded (too many + * colliding elements for its size) + */ +enum GNUNET_GenericReturnValue +SYNC_ibf_decode (struct SYNC_IBF *ibf, + int16_t *ret_side, + struct SYNC_IBFKey *ret_key); + + +#endif /* SYNC_IBF_H */ +\ No newline at end of file diff --git a/src/include/sync/sync_service.h b/src/include/sync/sync_service.h @@ -228,6 +228,114 @@ void SYNC_block_list_cancel (struct SYNC_BlockListOperation *op); +/* ********************* Block reconcile API *********************** */ + + +/** + * Result of a block reconcile (bloom filter) operation, as returned + * by GET /backups/$KEY/blocks/reconcile. + */ +struct SYNC_ReconcileDetails +{ + + /** + * HTTP status code. + */ + unsigned int http_status; + + /** + * Taler error code. + */ + enum TALER_ErrorCode ec; + + /** + * Version of the filter format. + */ + uint32_t version; + + /** + * Number of 32-bit buckets in the filter. + */ + uint32_t bucket_count; + + /** + * Number of bits set per element. + */ + unsigned int k; + + /** + * Mixing prefix the filter was built with. + */ + uint32_t prefix; + + /** + * Number of blocks in the account's linked list. + */ + uint32_t total_blocks; + + /** + * Number of bytes in @e data. + */ + size_t data_size; + + /** + * Serialized bloom filter (bucket_count * 4 bytes), allocated by + * the parser and freed by the client after the callback returns. + */ + void *data; + +}; + + +/** + * Callback for the reconcile operation. + * + * @param cls closure + * @param rd result details (only valid during the callback) + */ +typedef void +(*SYNC_ReconcileCallback)(void *cls, + const struct SYNC_ReconcileDetails *rd); + + +/** + * Handle for a block reconcile operation. + */ +struct SYNC_ReconcileOperation; + + +/** + * Fetch the bloom filter over the hashes of all the blocks of an + * account, used by wallets to reconcile their local copy of the + * backup with the server's (DD 92). + * + * @param ctx for HTTP client request processing + * @param base_url base URL of the Sync server + * @param pub account public key + * @param prefix optional mixing prefix to rebuild the filter with + * (used to get rid of false positives), NULL for the default + * @param cb function to call with the result + * @param cb_cls closure for @a cb + * @return handle for the operation + */ +struct SYNC_ReconcileOperation * +SYNC_block_reconcile (struct GNUNET_CURL_Context *ctx, + const char *base_url, + const struct SYNC_AccountPublicKeyP *pub, + const uint32_t *prefix, + SYNC_ReconcileCallback cb, + void *cb_cls); + + +/** + * Cancel a block reconcile operation. + * + * @param[in] op operation to cancel + */ +void +SYNC_block_reconcile_cancel (struct SYNC_ReconcileOperation *op); + + /* ********************* Block operation API *********************** */ diff --git a/src/include/sync/sync_testing_lib.h b/src/include/sync/sync_testing_lib.h @@ -144,6 +144,56 @@ SYNC_TESTING_cmd_block_list (const char *label, /** + * Make the "block reconcile" command: fetch the invertible bloom + * filter over the account's blocks (DD 92), rebuild the wallet's side + * of the reconciliation over the locally held blocks, and verify that + * the decoded difference is exactly as expected. + * + * @param label command label + * @param sync_url base URL of the sync serving + * @param account_reference reference to a previous command that + * provides the account public key + * @param http_status expected HTTP status. + * @param expected_total_blocks expected number of blocks the filter + * must report + * @param prefix optional mixing salt to request (NULL for none); + * the reply's prefix must match it + * @param num_local number of blocks the wallet holds locally + * @param local_nonces nonces of the locally held blocks + * (length @a num_local) + * @param local_hashes hashes of the locally held blocks + * (length @a num_local) + * @param num_missing number of blocks expected to be missing locally + * @param missing_nonces nonces of the blocks expected missing + * (length @a num_missing) + * @param missing_hashes hashes of the blocks expected missing + * (length @a num_missing) + * @param num_stale number of blocks expected to be stale locally + * @param stale_nonces nonces of the blocks expected stale + * (length @a num_stale) + * @param stale_hashes hashes of the blocks expected stale + * (length @a num_stale) + * @return the command + */ +struct TALER_TESTING_Command +SYNC_TESTING_cmd_block_reconcile (const char *label, + const char *sync_url, + const char *account_reference, + unsigned int http_status, + uint32_t expected_total_blocks, + const uint32_t *prefix, + unsigned int num_local, + const struct SYNC_BlockNonce *local_nonces, + const struct GNUNET_HashCode *local_hashes, + unsigned int num_missing, + const struct SYNC_BlockNonce *missing_nonces, + const struct GNUNET_HashCode *missing_hashes, + unsigned int num_stale, + const struct SYNC_BlockNonce *stale_nonces, + const struct GNUNET_HashCode *stale_hashes); + + +/** * Make the "block delete" command. * * @param label command label diff --git a/src/lib/meson.build b/src/lib/meson.build @@ -7,6 +7,7 @@ libsync_SOURCES = [ 'sync_api_block_update.c', 'sync_api_block_delete.c', 'sync_api_block_list.c', + 'sync_api_block_reconcile.c', 'sync_api_object_upload.c', 'sync_api_object_fetch.c', ] diff --git a/src/lib/sync_api_block_reconcile.c b/src/lib/sync_api_block_reconcile.c @@ -0,0 +1,336 @@ +/* + 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 lib/sync_api_block_reconcile.c + * @brief Implementation of the block reconcile GET + * @author Iván Ávalos + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include <gnunet/gnunet_json_lib.h> +#include <taler/taler_json_lib.h> +#include <taler/taler_curl_lib.h> +#include <sync/sync_service.h> +#include <sync/sync_util.h> +#include <sync/sync_ibf.h> +#include "sync_api_curl_defaults.h" + + +/** + * Filter format version accepted by this client. + */ +#define SYNC_RECONCILE_VERSION 2 + + +/** + * Handle for a block reconcile operation. + */ +struct SYNC_ReconcileOperation +{ + + /** + * The url for this request. + */ + char *url; + + /** + * Handle for the request. + */ + struct GNUNET_CURL_Job *job; + + /** + * Reference to the execution context. + */ + struct GNUNET_CURL_Context *ctx; + + /** + * Function to call with the result. + */ + SYNC_ReconcileCallback cb; + + /** + * Closure for @e cb. + */ + void *cb_cls; + +}; + + +/** + * Function called when we're done processing the + * GET /backups/$KEY/blocks/reconcile request. + * + * @param cls the `struct SYNC_ReconcileOperation *` + * @param response_code HTTP response code, 0 on error + * @param response response body as parsed JSON, or NULL + */ +static void +handle_reconcile_finished (void *cls, + long response_code, + const void *response) +{ + struct SYNC_ReconcileOperation *op = cls; + const json_t *json = response; + struct SYNC_ReconcileDetails rd = { + .http_status = (unsigned int) response_code, + .ec = TALER_EC_INVALID + }; + + op->job = NULL; + switch (response_code) + { + case 0: + break; + case MHD_HTTP_OK: + if (NULL != json) + { + const json_t *j; + const char *filter_s; + size_t filter_len; + + /* version */ + j = json_object_get (json, + "version"); + if ( (NULL == j) || + (! json_is_integer (j)) || + (SYNC_RECONCILE_VERSION != json_integer_value (j)) ) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + rd.version = (uint32_t) json_integer_value (j); + /* bucket_count */ + j = json_object_get (json, + "bucket_count"); + if ( (NULL == j) || + (! json_is_integer (j)) || + (json_integer_value (j) <= 0) ) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + rd.bucket_count = (uint32_t) json_integer_value (j); + /* k */ + j = json_object_get (json, + "k"); + if ( (NULL == j) || + (! json_is_integer (j)) || + (json_integer_value (j) <= 0) ) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + rd.k = (unsigned int) json_integer_value (j); + /* prefix */ + j = json_object_get (json, + "prefix"); + if (NULL == j) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + { + const char *prefix_s = json_string_value (j); + uint32_t prefix; + + if ( (NULL == prefix_s) || + (GNUNET_OK != + GNUNET_STRINGS_string_to_data (prefix_s, + strlen (prefix_s), + &prefix, + sizeof (prefix))) ) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + rd.prefix = ntohl (prefix); + } + /* total_blocks */ + j = json_object_get (json, + "total_blocks"); + if ( (NULL == j) || + (! json_is_integer (j)) || + (json_integer_value (j) < 0) ) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + rd.total_blocks = (uint32_t) json_integer_value (j); + /* filter */ + j = json_object_get (json, + "filter"); + if (NULL == j) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + filter_s = json_string_value (j); + if (NULL == filter_s) + { + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + rd.data_size = ((size_t) rd.bucket_count) * SYNC_IBF_BUCKET_SIZE; + rd.data = GNUNET_malloc (rd.data_size); + filter_len = strlen (filter_s); + if (GNUNET_OK != + GNUNET_STRINGS_string_to_data (filter_s, + filter_len, + rd.data, + rd.data_size)) + { + GNUNET_free (rd.data); + rd.data = NULL; + rd.data_size = 0; + GNUNET_break_op (0); + rd.ec = TALER_EC_GENERIC_PARAMETER_MALFORMED; + break; + } + rd.ec = TALER_EC_NONE; + } + else + { + rd.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + } + break; + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_PAYMENT_REQUIRED: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + default: + if ( (response_code >= 400) && + (response_code < 500) ) + rd.ec = (NULL != json) + ? TALER_JSON_get_error_code (json) + : TALER_EC_GENERIC_PARAMETER_MALFORMED; + else if (response_code >= 500) + rd.ec = (NULL != json) + ? TALER_JSON_get_error_code (json) + : TALER_EC_GENERIC_INVALID_RESPONSE; + break; + } + + if (NULL != op->cb) + { + op->cb (op->cb_cls, + &rd); + op->cb = NULL; + } + GNUNET_free (rd.data); + SYNC_block_reconcile_cancel (op); +} + + +struct SYNC_ReconcileOperation * +SYNC_block_reconcile (struct GNUNET_CURL_Context *ctx, + const char *base_url, + const struct SYNC_AccountPublicKeyP *pub, + const uint32_t *prefix, + SYNC_ReconcileCallback cb, + void *cb_cls) +{ + struct SYNC_ReconcileOperation *op; + CURL *eh; + char *pub_s; + char *path; + char *url; + + pub_s = GNUNET_STRINGS_data_to_string_alloc (pub, + sizeof (*pub)); + GNUNET_asprintf (&path, + "backups/%s/blocks/reconcile", + pub_s); + GNUNET_free (pub_s); + if (NULL != prefix) + { + uint32_t prefix_be = htonl (*prefix); + char *prefix_s; + + prefix_s = GNUNET_STRINGS_data_to_string_alloc (&prefix_be, + sizeof (prefix_be)); + url = TALER_url_join (base_url, + path, + "prefix", + prefix_s, + NULL); + GNUNET_free (prefix_s); + } + else + { + url = TALER_url_join (base_url, + path, + NULL); + } + GNUNET_free (path); + if (NULL == url) + { + GNUNET_break (0); + return NULL; + } + + op = GNUNET_new (struct SYNC_ReconcileOperation); + op->ctx = ctx; + op->cb = cb; + op->cb_cls = cb_cls; + op->url = url; + + eh = SYNC_curl_easy_get_ (op->url); + if (NULL == eh) + { + GNUNET_break (0); + GNUNET_free (op->url); + GNUNET_free (op); + return NULL; + } + + op->job = GNUNET_CURL_job_add2 (ctx, + eh, + NULL, + &handle_reconcile_finished, + op); + return op; +} + + +void +SYNC_block_reconcile_cancel (struct SYNC_ReconcileOperation *op) +{ + if (NULL != op->job) + { + GNUNET_CURL_job_cancel (op->job); + op->job = NULL; + } + GNUNET_free (op->url); + GNUNET_free (op); +} + + +/* end of sync_api_block_reconcile.c */ +\ No newline at end of file diff --git a/src/sync/meson.build b/src/sync/meson.build @@ -12,6 +12,7 @@ executable( 'sync-httpd2_post-backups-KEY-objects-UID.c', 'sync-httpd2_get-backups-KEY.c', 'sync-httpd2_get-backups-KEY-blocks.c', + 'sync-httpd2_get-backups-KEY-blocks-reconcile.c', 'sync-httpd2_payments.c', ], dependencies: [ diff --git a/src/sync/sync-httpd2.c b/src/sync/sync-httpd2.c @@ -30,6 +30,7 @@ #include "sync-httpd2_post-backups-KEY-objects-UID.h" #include "sync-httpd2_get-backups-KEY.h" #include "sync-httpd2_get-backups-KEY-blocks.h" +#include "sync-httpd2_get-backups-KEY-blocks-reconcile.h" /** @@ -340,6 +341,13 @@ handle_backup_subpath (struct MHD_Request *request, .nargs = 0, }, { + .operation = "blocks", + .method = MHD_HTTP_METHOD_GET, + .handler = &SH_backup_blocks_reconcile_get, + .nargs = 1, + .nargs_is_upper_bound = true, + }, + { .operation = "objects", .method = MHD_HTTP_METHOD_GET, .handler = &SH_backup_object_get, @@ -419,12 +427,10 @@ handle_backup_subpath (struct MHD_Request *request, ? (nargs > bh->nargs) : (nargs != bh->nargs)) { - GNUNET_break_op (0); - return TALER_MHD2_reply_with_error ( - request, - MHD_HTTP_STATUS_NOT_FOUND, - TALER_EC_GENERIC_ENDPOINT_UNKNOWN, - subpath); + /* Not this handler's shape; there may be another handler for + the same (operation, method) with a different number of + arguments (e.g. GET blocks vs. GET blocks/reconcile). */ + continue; } return bh->handler (request, account_pub, diff --git a/src/sync/sync-httpd2_get-backups-KEY-blocks-reconcile.c b/src/sync/sync-httpd2_get-backups-KEY-blocks-reconcile.c @@ -0,0 +1,283 @@ +/* + 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 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file sync-httpd2_get-backups-KEY-blocks-reconcile.c + * @brief GET /backups/$KEY/blocks/reconcile — invertible bloom filter + * over the account's blocks (DD 92) + * @author Iván Ávalos + */ +#include "sync-httpd2.h" +#include "sync-httpd2_get-backups-KEY-blocks-reconcile.h" +#include <gnunet/gnunet_json_lib.h> +#include <taler/taler_json_lib.h> +#include <sync/sync_util.h> +#include <sync/sync_ibf.h> + + +/** + * Version of the filter format served by this endpoint. + */ +#define SYNC_RECONCILE_VERSION 2 + + +/** + * Iterator context for collecting the block identities into the + * invertible bloom filter. + */ +struct ReconcileHashContext +{ + /** + * Filter being built. + */ + struct SYNC_IBF *ibf; + + /** + * Number of blocks fed into the filter (sanity check against the + * counting pass). + */ + uint32_t count; +}; + + +/** + * Iterator called for every block of the account, adding its identity + * (nonce + hash) to the filter. + * + * @param cls a `struct ReconcileHashContext *` + * @param nonce nonce identifying the block + * @param block_hash hash of the block data + */ +static void +reconcile_block_iter (void *cls, + const struct SYNC_BlockNonce *nonce, + const struct GNUNET_HashCode *block_hash) +{ + struct ReconcileHashContext *rhc = cls; + struct SYNC_IBFKey key; + + rhc->count++; + memcpy (key.nonce, + nonce, + sizeof (key.nonce)); + memcpy (key.hash, + block_hash, + sizeof (key.hash)); + SYNC_ibf_insert (rhc->ibf, + &key); +} + + +const struct MHD_Action * +SH_backup_blocks_reconcile_get (struct MHD_Request *request, + const struct SYNC_AccountPublicKeyP *account_pub + , + const char *const args[], + uint_fast64_t upload_size, + bool is_update) +{ + struct ReconcileHashContext rhc; + enum SYNC_DB_QueryStatus qs; + uint32_t total_blocks; + uint32_t bucket_count; + uint32_t prefix = 0; + struct MHD_StringNullable prefix_str; + + (void) args; + (void) upload_size; + (void) is_update; + + /* Optional "prefix" query parameter: a fresh mixing salt to rebuild + the filter with (4 bytes, base32, network byte order). */ + if (MHD_YES == + MHD_request_get_value (request, + MHD_VK_URI_QUERY_PARAM, + "prefix", + &prefix_str)) + { + uint32_t prefix_be; + + if ( (NULL == prefix_str.cstr) || + (GNUNET_OK != + GNUNET_STRINGS_string_to_data (prefix_str.cstr, + prefix_str.len, + &prefix_be, + sizeof (prefix_be))) ) + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "prefix"); + prefix = ntohl (prefix_be); + } + + /* Counting pass: how many blocks does the account hold? This + decides the size of the filter. */ + qs = SYNCDB_lookup_block_hashes_TR (account_pub, + NULL, + NULL); + + switch (qs) + { + case SYNC_DB_NO_RESULTS: + total_blocks = 0; + break; + + case SYNC_DB_PAYMENT_REQUIRED: + /* Deliberately not 404, mirroring GET /backups/$KEY/blocks: the + account does not exist until it has been paid for. */ + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_PAYMENT_REQUIRED, + TALER_EC_SYNC_ACCOUNT_UNKNOWN, + NULL); + + case SYNC_DB_HARD_ERROR: + GNUNET_break (0); + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + NULL); + + case SYNC_DB_SOFT_ERROR: + GNUNET_break (0); + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + NULL); + + default: + if (qs < 0) + { + GNUNET_break (0); + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_INTERNAL_SERVER_ERROR, + TALER_EC_SYNC_GENERIC_BACKEND_ERROR, + NULL); + } + total_blocks = (uint32_t) qs; + break; + } + + /* An empty account yields an empty (all-zero) filter: a wallet + with nothing to reconcile sees total_blocks 0 and is done. */ + if (0 == total_blocks) + bucket_count = SYNC_IBF_MIN_BUCKETS; + else + bucket_count = SYNC_ibf_compute_bucket_count (total_blocks); + rhc.ibf = SYNC_ibf_create (bucket_count, + SYNC_IBF_HASH_NUM, + prefix); + if (NULL == rhc.ibf) + { + GNUNET_break (0); + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + NULL); + } + + /* Streaming pass: build the filter over the block identities. */ + if (0 < total_blocks) + { + rhc.count = 0; + qs = SYNCDB_lookup_block_hashes_TR (account_pub, + &reconcile_block_iter, + &rhc); + if (qs < 0) + { + GNUNET_break (0); + SYNC_ibf_destroy (rhc.ibf); + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + NULL); + } + GNUNET_assert (qs == (enum SYNC_DB_QueryStatus) total_blocks); + GNUNET_assert (rhc.count == total_blocks); + } + + /* Serialize (network byte order) and base32-encode. */ + { + size_t filter_size; + char *filter_s; + + filter_size = SYNC_ibf_get_serialized_size (rhc.ibf); + { + void *filter_data = GNUNET_malloc (filter_size); + + SYNC_ibf_serialize (rhc.ibf, + filter_data); + filter_s = GNUNET_STRINGS_data_to_string_alloc (filter_data, + filter_size); + GNUNET_free (filter_data); + } + SYNC_ibf_destroy (rhc.ibf); + if (NULL == filter_s) + { + GNUNET_break (0); + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + NULL); + } + { + uint32_t prefix_be = htonl (prefix); + char *prefix_s; + json_t *resp; + + prefix_s = GNUNET_STRINGS_data_to_string_alloc (&prefix_be, + sizeof (prefix_be)); + if (NULL == prefix_s) + { + GNUNET_break (0); + GNUNET_free (filter_s); + return TALER_MHD2_reply_with_error ( + request, + MHD_HTTP_STATUS_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + NULL); + } + resp = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_uint64 ("version", + SYNC_RECONCILE_VERSION), + GNUNET_JSON_pack_uint64 ("bucket_count", + bucket_count), + GNUNET_JSON_pack_uint64 ("k", + SYNC_IBF_HASH_NUM), + GNUNET_JSON_pack_string ("prefix", + prefix_s), + GNUNET_JSON_pack_uint64 ("total_blocks", + total_blocks), + GNUNET_JSON_pack_string ("filter", + filter_s)); + /* the pack helpers copy the strings */ + GNUNET_free (prefix_s); + GNUNET_free (filter_s); + return TALER_MHD2_reply_json_steal (request, + resp, + MHD_HTTP_STATUS_OK); + } + } +} + + +/* end of sync-httpd2_get-backups-KEY-blocks-reconcile.c */ +\ No newline at end of file diff --git a/src/sync/sync-httpd2_get-backups-KEY-blocks-reconcile.h b/src/sync/sync-httpd2_get-backups-KEY-blocks-reconcile.h @@ -0,0 +1,48 @@ +/* + 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 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file sync-httpd2_get-backups-KEY-blocks-reconcile.h + * @brief GET /backups/$KEY/blocks/reconcile — bloom filter over the + * account's blocks (DD 92) + * @author Iván Ávalos + */ +#ifndef SYNC_HTTPD2_GET_BACKUPS_KEY_BLOCKS_RECONCILE_H +#define SYNC_HTTPD2_GET_BACKUPS_KEY_BLOCKS_RECONCILE_H + +#include <microhttpd2.h> +#include "sync/sync_service.h" + + +/** + * Handle GET /backups/$KEY/blocks/reconcile. + * + * @param request the MHD request to handle + * @param account_pub public key of the account + * @param args path segments (args[0]="reconcile") + * @param upload_size number of bytes being uploaded + * @param is_update whether the operation is an update + * @return MHD action + */ +const struct MHD_Action * +SH_backup_blocks_reconcile_get (struct MHD_Request *request, + const struct SYNC_AccountPublicKeyP *account_pub + , + const char *const args[], + uint_fast64_t upload_size, + bool is_update); + + +#endif +\ No newline at end of file diff --git a/src/syncdb/meson.build b/src/syncdb/meson.build @@ -6,7 +6,7 @@ sqldir = get_option('datadir') / 'sync' / 'sql' # FIXME possibly provide this output in the tgz through dist script run_command('make', '-f', 'Makefile.sql', 'all', check: true) -sqlfiles = ['versioning.sql', 'procedures.sql', 'sync-0001.sql', 'drop.sql'] +sqlfiles = ['versioning.sql', 'procedures.sql', 'sync-0001.sql', 'sync-0002.sql', 'drop.sql'] install_data(sources: sqlfiles, install_dir: sqldir) @@ -27,6 +27,7 @@ libsyncdb_SOURCES = [ 'syncdb_update_block_TR.c', 'syncdb_lookup_account_TR.c', 'syncdb_lookup_block_range_TR.c', + 'syncdb_lookup_block_hashes_TR.c', 'syncdb_lookup_block_TR.c', 'syncdb_increment_lifetime_TR.c', 'syncdb_lookup_pending_payments_by_account.c', diff --git a/src/syncdb/procedures.sql.in b/src/syncdb/procedures.sql.in @@ -27,6 +27,7 @@ SET search_path TO sync; #include "syncdb_delete_block_TR.sql" #include "syncdb_update_block_TR.sql" #include "syncdb_lookup_block_range_TR.sql" +#include "syncdb_lookup_block_hashes_TR.sql" #include "syncdb_lookup_block_TR.sql" #include "syncdb_gc.sql" diff --git a/src/syncdb/sync-0002.sql b/src/syncdb/sync-0002.sql @@ -0,0 +1,36 @@ +-- +-- 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; + +-- Check patch versioning is in place. +SELECT _v.register_patch('sync-0002', NULL, NULL); + +SET search_path TO sync; + +-- Return type of sync_do_lookup_block_hashes(), added for the +-- reconcile (invertible bloom filter) mechanism (DD 92). +CREATE TYPE sync_do_lookup_block_hashes_return_type + AS + (out_account_missing BOOLEAN + ,out_backup_empty BOOLEAN + ,nonce BYTEA + ,block_hash BYTEA + ); + +-- Complete transaction +COMMIT; +\ No newline at end of file diff --git a/src/syncdb/syncdb_lookup_block_hashes_TR.c b/src/syncdb/syncdb_lookup_block_hashes_TR.c @@ -0,0 +1,176 @@ +/* + This file is part of TALER + (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser 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 syncdb/syncdb_lookup_block_hashes_TR.c + * @brief lookup the hashes of all the blocks of an account + * @author Iván Ávalos + */ +#include "syncdb_pg.h" + + +/** + * Closure for the hash iterator callback. + */ +struct HashIteratorContext +{ + /** + * Iterator function to call for every block. + */ + SYNC_DB_BlockHashIterator it; + + /** + * Closure for @e it. + */ + void *it_cls; + + /** + * Query status to return. + */ + enum SYNC_DB_QueryStatus qs; + + /** + * Number of block hashes returned. + */ + uint32_t num_hashes; + + /** + * True if the account does not exist. + */ + bool out_account_missing; + + /** + * True if the account has no blocks at all. + */ + bool out_backup_empty; +}; + + +/** + * Helper function for #SYNCDB_lookup_block_hashes_TR(). + * To be called with the results of a SELECT statement + * that has returned @a num_results results. + * + * @param cls closure of type `struct HashIteratorContext *` + * @param result the postgres result + * @param num_results the number of results in @a result + */ +static void +block_hashes_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct HashIteratorContext *hic = cls; + + for (unsigned int i = 0; i < num_results; i++) + { + bool out_account_missing; + bool out_backup_empty; + struct SYNC_BlockNonce nonce; + struct GNUNET_HashCode block_hash; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("out_account_missing", + &out_account_missing), + GNUNET_PQ_result_spec_bool ("out_backup_empty", + &out_backup_empty), + GNUNET_PQ_result_spec_auto_from_type ("nonce", + &nonce), + GNUNET_PQ_result_spec_auto_from_type ("block_hash", + &block_hash), + GNUNET_PQ_result_spec_end, + }; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + hic->qs = SYNC_DB_SOFT_ERROR; + return; + } + + hic->qs = i + 1; + hic->out_account_missing = out_account_missing; + hic->out_backup_empty = out_backup_empty; + if ( (! out_account_missing) && + (! out_backup_empty) ) + { + /* Count the block even in the counting pass (NULL iterator) */ + hic->num_hashes++; + if (NULL != hic->it) + hic->it (hic->it_cls, + &nonce, + &block_hash); + } + GNUNET_PQ_cleanup_result (rs); + } +} + + +enum SYNC_DB_QueryStatus +SYNCDB_lookup_block_hashes_TR ( + const struct SYNC_AccountPublicKeyP *account_pub, + SYNC_DB_BlockHashIterator it, + void *it_cls) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (account_pub), + GNUNET_PQ_query_param_end, + }; + struct HashIteratorContext hic = { + .it = it, + .it_cls = it_cls, + }; + enum GNUNET_DB_QueryStatus qs; + + SYNCDB_preflight (); + PREPARE ("blocks_select_hashes", + "SELECT" + " out_account_missing" + ",out_backup_empty" + ",nonce" + ",block_hash" + " FROM sync_do_lookup_block_hashes" + " ($1)"); + qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn, + "blocks_select_hashes", + params, + &block_hashes_cb, + &hic); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + return SYNC_DB_HARD_ERROR; + case GNUNET_DB_STATUS_SOFT_ERROR: + return SYNC_DB_SOFT_ERROR; + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + /* the stored procedure always returns a status row */ + GNUNET_break (0); + return SYNC_DB_NO_RESULTS; + default: + break; + } + /* map the flags of the (single) status row to SYNC_DB codes */ + if (hic.out_account_missing) + return SYNC_DB_PAYMENT_REQUIRED; + if (hic.out_backup_empty) + return SYNC_DB_NO_RESULTS; + return hic.num_hashes; +} + + +/* end of syncdb_lookup_block_hashes_TR.c */ +\ No newline at end of file diff --git a/src/syncdb/syncdb_lookup_block_hashes_TR.sql b/src/syncdb/syncdb_lookup_block_hashes_TR.sql @@ -0,0 +1,69 @@ +-- +-- 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/> +-- + +DROP FUNCTION IF EXISTS sync_do_lookup_block_hashes; +CREATE FUNCTION sync_do_lookup_block_hashes ( + IN in_account_pub BYTEA) +RETURNS SETOF sync_do_lookup_block_hashes_return_type +LANGUAGE plpgsql +AS $$ +DECLARE + my_first_block BYTEA; + r RECORD; +BEGIN + + -- Check if account exists + SELECT first_block + INTO my_first_block + FROM accounts + WHERE account_pub = in_account_pub; + + IF NOT FOUND + THEN + RETURN NEXT (TRUE -- out_account_missing + ,FALSE + ,decode(repeat('00', 24), 'hex') + ,decode(repeat('00', 64), 'hex')); + RETURN; + END IF; + + -- Check if backup is empty + IF my_first_block IS NULL + THEN + RETURN NEXT (FALSE + ,TRUE -- out_backup_empty + ,decode(repeat('00', 24), 'hex') + ,decode(repeat('00', 64), 'hex')); + RETURN; + END IF; + + -- Stream the identities of all the blocks in the account's linked + -- list (the order does not matter, the caller builds an invertible + -- bloom filter over them). + FOR r IN + SELECT nonce + ,block_hash + FROM blocks + WHERE account_pub = in_account_pub + LOOP + RETURN NEXT (FALSE + ,FALSE + ,r.nonce + ,r.block_hash); + END LOOP; + + RETURN; +END $$; +\ No newline at end of file diff --git a/src/syncdb/test_sync_db.c b/src/syncdb/test_sync_db.c @@ -111,6 +111,50 @@ block_it (void *cls, /** + * Iterator used by the #SYNCDB_lookup_block_hashes_TR() tests, + * collecting the block identities it is called with. + */ +struct HashCollector +{ + /** + * Nonces collected so far. + */ + struct SYNC_BlockNonce nonces[8]; + + /** + * Hashes collected so far. + */ + struct GNUNET_HashCode hashes[8]; + + /** + * Number of entries collected. + */ + unsigned int count; +}; + + +/** + * Collect the block identities the iterator is called with. + * + * @param cls a `struct HashCollector *` + * @param nonce nonce identifying the block + * @param block_hash hash of the block data + */ +static void +hash_it (void *cls, + const struct SYNC_BlockNonce *nonce, + const struct GNUNET_HashCode *block_hash) +{ + struct HashCollector *hc = cls; + + GNUNET_assert (hc->count < 8); + hc->nonces[hc->count] = *nonce; + hc->hashes[hc->count] = *block_hash; + hc->count++; +} + + +/** * Create a paid account, so that the SQL entry points do not reject * it with #SYNC_DB_PAYMENT_REQUIRED. * @@ -486,6 +530,90 @@ run (void *cls) NULL, NULL)); } + + /* --- Block hash lookup (reconcile) tests --- */ + { + struct SYNC_AccountPublicKeyP unknown; + struct SYNC_AccountPublicKeyP lone; + struct SYNC_BlockNonce lone_nonce; + struct GNUNET_HashCode lone_hash; + char lone_data[] = "lone-block"; + struct HashCollector hc; + + /* An account nobody has heard of demands payment */ + memset (&unknown, 9, sizeof (unknown)); + FAILIF (SYNC_DB_PAYMENT_REQUIRED != + SYNCDB_lookup_block_hashes_TR (&unknown, + NULL, + NULL)); + + /* A fresh, paid account with a single block delivers exactly + that block's hash */ + memset (&lone, 16, sizeof (lone)); + FAILIF (GNUNET_OK != + activate_account (&lone, + "fake-order-hashes-lone")); + RND_BLK (&lone_nonce); + GNUNET_CRYPTO_hash (lone_data, + sizeof lone_data - 1, + &lone_hash); + FAILIF (SYNC_DB_ONE_RESULT != + SYNCDB_append_block_TR (&lone, + &lone_nonce, + NULL, + &lone_hash, + sizeof lone_data - 1, + lone_data, + 0, + NULL, + NULL, + &fake_sig, + &empty_refs, + NULL, + NULL, + NULL)); + memset (&hc, 0, sizeof (hc)); + FAILIF (1 != + SYNCDB_lookup_block_hashes_TR (&lone, + &hash_it, + &hc)); + FAILIF (1 != hc.count); + FAILIF (0 != GNUNET_memcmp (&hc.hashes[0], + &lone_hash)); + FAILIF (0 != GNUNET_memcmp (&hc.nonces[0], + &lone_nonce)); + + /* Counting pass with a NULL iterator */ + FAILIF (1 != + SYNCDB_lookup_block_hashes_TR (&lone, + NULL, + NULL)); + + /* The DLL account now holds blocks A and C (B was deleted, C + was updated), whose identities must both be delivered */ + memset (&hc, 0, sizeof (hc)); + FAILIF (2 != + SYNCDB_lookup_block_hashes_TR (&account_pub, + &hash_it, + &hc)); + FAILIF (2 != hc.count); + { + bool have_a = false; + bool have_c = false; + + for (unsigned int i = 0; i < hc.count; i++) + { + if (0 == GNUNET_memcmp (&hc.hashes[i], + &hA)) + have_a = true; + if (0 == GNUNET_memcmp (&hc.hashes[i], + &hC)) + have_c = true; + } + FAILIF (! have_a); + FAILIF (! have_c); + } + } } /* --- Account status lookup --- */ diff --git a/src/testing/meson.build b/src/testing/meson.build @@ -8,6 +8,7 @@ libsynctesting = library( 'testing_api_cmd_block_update.c', 'testing_api_cmd_block_delete.c', 'testing_api_cmd_block_list.c', + 'testing_api_cmd_block_reconcile.c', 'testing_api_cmd_object_upload.c', 'testing_api_cmd_object_fetch.c', ], diff --git a/src/testing/test_sync_api.c b/src/testing/test_sync_api.c @@ -139,6 +139,8 @@ run (void *cls, struct SYNC_BlockNonce nonces[3]; /* Hashes of blocks A, C and D, in chain order. */ struct GNUNET_HashCode hashes[3]; + /* Mixing salt used for the reconcile tests. */ + uint32_t reconcile_prefix; /* Nonce of a block that is never successfully uploaded. */ struct SYNC_BlockNonce nonce_stale; /* Nonce that never exists on the server. */ @@ -177,6 +179,7 @@ run (void *cls, GNUNET_CRYPTO_hash (data[i + 1], strlen (data[i + 1]), &hashes[i]); + reconcile_prefix = 0x01020304; { struct TALER_TESTING_Command commands[] = { @@ -407,6 +410,102 @@ run (void *cls, 0, NULL, NULL), + + /* ========== Reconcile (invertible bloom filter) tests ========== */ + + /* A wallet holding exactly the server's blocks decodes an empty + difference from the filter. */ + SYNC_TESTING_cmd_block_reconcile ("reconcile-in-sync", + sync_url, + "upload-block-2", + MHD_HTTP_OK, + 2, + NULL, + 2, + nonces, + hashes, + 0, + NULL, + NULL, + 0, + NULL, + NULL), + /* With a fresh salt the filter still matches every block, and + the reply echoes the requested salt. */ + SYNC_TESTING_cmd_block_reconcile ("reconcile-prefix", + sync_url, + "upload-block-2", + MHD_HTTP_OK, + 2, + &reconcile_prefix, + 2, + nonces, + hashes, + 0, + NULL, + NULL, + 0, + NULL, + NULL), + /* A wallet that still holds the deleted block D sees it as + stale (local-only); nothing is missing. */ + SYNC_TESTING_cmd_block_reconcile ("reconcile-stale", + sync_url, + "upload-block-2", + MHD_HTTP_OK, + 2, + NULL, + 3, + nonces, + hashes, + 0, + NULL, + NULL, + 1, + &nonces[2], + &hashes[2]), + /* A wallet holding only block A enumerates block B as missing + (server-only). */ + SYNC_TESTING_cmd_block_reconcile ("reconcile-missing", + sync_url, + "upload-block-2", + MHD_HTTP_OK, + 2, + NULL, + 1, + nonces, + hashes, + 1, + &nonces[1], + &hashes[1], + 0, + NULL, + NULL), + + /* ========== Single block fetch tests ========== */ + + /* A block is fetchable by nonce through the list endpoint with + limit 1 and an inclusive start_nonce, with its payload and + signature intact. */ + SYNC_TESTING_cmd_block_list ("fetch-block-B", + sync_url, + "upload-block-2", + 1, + &nonces[1], + MHD_HTTP_OK, + 1, + &nonces[1], + &hashes[1]), + /* A nonce that is not part of the linked list is not found. */ + SYNC_TESTING_cmd_block_list ("fetch-block-unknown", + sync_url, + "upload-block-2", + 1, + &bogus_nonce, + MHD_HTTP_NOT_FOUND, + 0, + NULL, + NULL), /* Final list confirms all is well */ SYNC_TESTING_cmd_block_list ("list-blocks-end", sync_url, diff --git a/src/testing/testing_api_cmd_block_reconcile.c b/src/testing/testing_api_cmd_block_reconcile.c @@ -0,0 +1,514 @@ +/* + This file is part of SYNC + Copyright (C) 2026 Taler Systems SA + + SYNC 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. + + SYNC 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 SYNC; see the file COPYING. If not, see + <http://www.gnu.org/licenses/> +*/ +/** + * @file testing/testing_api_cmd_block_reconcile.c + * @brief command to fetch and verify the invertible bloom filter of an + * account from the sync backend service (DD 92) + * @author Iván Ávalos + */ +#include "platform.h" +#include "sync/sync_service.h" +#include "sync/sync_signatures.h" +#include "sync/sync_ibf.h" +#include "sync/sync_testing_lib.h" +#include <taler/taler_util.h> +#include <taler/taler_testing_lib.h> +#include <gnunet/gnunet_curl_lib.h> + + +/** + * Maximum number of entries in the local set and in the expected + * differences. + */ +#define MAX_RECONCILE_ENTRIES 256 + + +/** + * State for a "block reconcile" CMD. + */ +struct BlockReconcileState +{ + + /** + * URL of the sync backend. + */ + const char *sync_url; + + /** + * Reference to a command that provides the account public key. + */ + const char *account_reference; + + /** + * Account public key to query. + */ + struct SYNC_AccountPublicKeyP account_pub; + + /** + * Optional mixing salt to request (NULL for none). + */ + uint32_t *prefix; + + /** + * Expected HTTP status code. + */ + unsigned int http_status; + + /** + * Expected number of blocks the filter must report. + */ + uint32_t expected_total_blocks; + + /** + * The interpreter state. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Handle for the library block reconcile operation. + */ + struct SYNC_ReconcileOperation *ro; + + /** + * Number of blocks the wallet holds locally. + */ + unsigned int num_local; + + /** + * Nonces of the locally held blocks (array of @e num_local). + */ + struct SYNC_BlockNonce local_nonces[MAX_RECONCILE_ENTRIES]; + + /** + * Hashes of the locally held blocks (array of @e num_local). + */ + struct GNUNET_HashCode local_hashes[MAX_RECONCILE_ENTRIES]; + + /** + * Number of blocks expected to be missing locally (server-only). + */ + unsigned int num_missing; + + /** + * Nonces of the blocks expected missing (array of @e num_missing). + */ + struct SYNC_BlockNonce missing_nonces[MAX_RECONCILE_ENTRIES]; + + /** + * Hashes of the blocks expected missing (array of @e num_missing). + */ + struct GNUNET_HashCode missing_hashes[MAX_RECONCILE_ENTRIES]; + + /** + * Number of blocks expected to be stale locally (local-only). + */ + unsigned int num_stale; + + /** + * Nonces of the blocks expected stale (array of @e num_stale). + */ + struct SYNC_BlockNonce stale_nonces[MAX_RECONCILE_ENTRIES]; + + /** + * Hashes of the blocks expected stale (array of @e num_stale). + */ + struct GNUNET_HashCode stale_hashes[MAX_RECONCILE_ENTRIES]; + +}; + + +/** + * Look up a decoded element in the expected arrays. + * + * @param key element to find (nonce || hash) + * @param num number of entries in @a nonces and @a hashes + * @param nonces expected nonces + * @param hashes expected hashes + * @param[out] used set to true when found (for duplicate checks) + * @return #GNUNET_YES if found, #GNUNET_NO if not + */ +static enum GNUNET_GenericReturnValue +match_expected (const struct SYNC_IBFKey *key, + unsigned int num, + const struct SYNC_BlockNonce *nonces, + const struct GNUNET_HashCode *hashes, + bool *used) +{ + for (unsigned int i = 0; i < num; i++) + { + if (used[i]) + continue; + if ( (0 == memcmp (key->nonce, + &nonces[i], + sizeof (key->nonce))) && + (0 == memcmp (key->hash, + &hashes[i], + sizeof (key->hash))) ) + { + used[i] = true; + return GNUNET_YES; + } + } + return GNUNET_NO; +} + + +/** + * Function called when we're done processing the block reconcile. + * + * @param cls the `struct BlockReconcileState` + * @param rd details about the reconcile operation + */ +static void +handle_reconcile_finished (void *cls, + const struct SYNC_ReconcileDetails *rd) +{ + struct BlockReconcileState *brs = cls; + struct SYNC_IBF *server_ibf; + struct SYNC_IBF *local_ibf; + bool used_missing[MAX_RECONCILE_ENTRIES] = { false }; + bool used_stale[MAX_RECONCILE_ENTRIES] = { false }; + + brs->ro = NULL; + if (rd->http_status != brs->http_status) + { + TALER_TESTING_unexpected_status (brs->is, + rd->http_status, + brs->http_status); + return; + } + if (MHD_HTTP_OK != rd->http_status) + { + /* error case: nothing more to check */ + TALER_TESTING_interpreter_next (brs->is); + return; + } + + if (rd->total_blocks != brs->expected_total_blocks) + { + GNUNET_break_op (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Expected %u blocks in reconcile, got %u\n", + (unsigned int) brs->expected_total_blocks, + (unsigned int) rd->total_blocks); + TALER_TESTING_interpreter_fail (brs->is); + return; + } + if ( (NULL != brs->prefix) && + (*brs->prefix != rd->prefix) ) + { + GNUNET_break_op (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Reconcile used prefix %u, expected %u\n", + (unsigned int) rd->prefix, + (unsigned int) *brs->prefix); + TALER_TESTING_interpreter_fail (brs->is); + return; + } + + server_ibf = SYNC_ibf_deserialize (rd->data, + rd->bucket_count, + rd->k, + rd->prefix); + if (NULL == server_ibf) + { + GNUNET_break_op (0); + TALER_TESTING_interpreter_fail (brs->is); + return; + } + /* Rebuild the wallet's side of the reconciliation, as it would: the + local filter over the blocks it holds, subtracted from the + server's, enumerates exactly the difference. */ + local_ibf = SYNC_ibf_create (rd->bucket_count, + rd->k, + rd->prefix); + if (NULL == local_ibf) + { + GNUNET_break_op (0); + SYNC_ibf_destroy (server_ibf); + TALER_TESTING_interpreter_fail (brs->is); + return; + } + for (unsigned int i = 0; i < brs->num_local; i++) + { + struct SYNC_IBFKey key; + + memcpy (key.nonce, + &brs->local_nonces[i], + sizeof (key.nonce)); + memcpy (key.hash, + &brs->local_hashes[i], + sizeof (key.hash)); + SYNC_ibf_insert (local_ibf, + &key); + } + SYNC_ibf_subtract (server_ibf, + local_ibf); + { + bool mismatch = false; + unsigned int got_missing = 0; + unsigned int got_stale = 0; + + for (;;) + { + enum GNUNET_GenericReturnValue res; + struct SYNC_IBFKey key; + int16_t side; + + res = SYNC_ibf_decode (server_ibf, + &side, + &key); + if (GNUNET_YES != res) + { + if (GNUNET_NO != res) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Reconcile filter cannot be decoded (difference" + " too large for its size)\n"); + mismatch = true; + } + break; + } + if (1 == side) + { + if (GNUNET_YES != + match_expected (&key, + brs->num_missing, + brs->missing_nonces, + brs->missing_hashes, + used_missing)) + { + GNUNET_break_op (0); + mismatch = true; + break; + } + got_missing++; + } + else + { + if (GNUNET_YES != + match_expected (&key, + brs->num_stale, + brs->stale_nonces, + brs->stale_hashes, + used_stale)) + { + GNUNET_break_op (0); + mismatch = true; + break; + } + got_stale++; + } + } + if ( (! mismatch) && + (got_missing != brs->num_missing) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Reconcile decoded %u missing blocks, expected %u\n", + got_missing, + brs->num_missing); + mismatch = true; + } + if ( (! mismatch) && + (got_stale != brs->num_stale) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Reconcile decoded %u stale blocks, expected %u\n", + got_stale, + brs->num_stale); + mismatch = true; + } + if (mismatch) + { + GNUNET_break_op (0); + TALER_TESTING_interpreter_fail (brs->is); + SYNC_ibf_destroy (local_ibf); + SYNC_ibf_destroy (server_ibf); + return; + } + } + SYNC_ibf_destroy (local_ibf); + SYNC_ibf_destroy (server_ibf); + TALER_TESTING_interpreter_next (brs->is); +} + + +/** + * Run a "block reconcile" CMD. + * + * @param cls closure. + * @param cmd command currently being run. + * @param is interpreter state. + */ +static void +reconcile_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct BlockReconcileState *brs = cls; + + (void) cmd; + brs->is = is; + + if (NULL != brs->account_reference) + { + const struct TALER_TESTING_Command *ref; + + ref = TALER_TESTING_interpreter_lookup_command (is, + brs->account_reference); + if (NULL == ref) + { + GNUNET_break (0); + goto fail; + } + { + const struct SYNC_AccountPublicKeyP *pub; + + if (GNUNET_OK != + TALER_TESTING_get_trait_sync_account_pub (ref, + &pub)) + goto fail; + brs->account_pub = *pub; + } + } + else + goto fail; + + brs->ro = SYNC_block_reconcile ( + TALER_TESTING_interpreter_get_context (is), + brs->sync_url, + &brs->account_pub, + brs->prefix, + &handle_reconcile_finished, + brs); + if (NULL == brs->ro) + goto fail; + + return; + +fail: + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); +} + + +/** + * Free the state of a "block reconcile" CMD, and possibly + * cancel it if it did not complete. + * + * @param cls closure. + * @param cmd command being freed. + */ +static void +reconcile_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct BlockReconcileState *brs = cls; + + if (NULL != brs->ro) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Command '%s' did not complete (block reconcile)\n", + cmd->label); + SYNC_block_reconcile_cancel (brs->ro); + brs->ro = NULL; + } + GNUNET_free (brs->prefix); + GNUNET_free (brs); +} + + +struct TALER_TESTING_Command +SYNC_TESTING_cmd_block_reconcile (const char *label, + const char *sync_url, + const char *account_reference, + unsigned int http_status, + uint32_t expected_total_blocks, + const uint32_t *prefix, + unsigned int num_local, + const struct SYNC_BlockNonce *local_nonces, + const struct GNUNET_HashCode *local_hashes, + unsigned int num_missing, + const struct SYNC_BlockNonce *missing_nonces, + const struct GNUNET_HashCode *missing_hashes, + unsigned int num_stale, + const struct SYNC_BlockNonce *stale_nonces, + const struct GNUNET_HashCode *stale_hashes) +{ + struct BlockReconcileState *brs; + + GNUNET_assert (num_local <= MAX_RECONCILE_ENTRIES); + GNUNET_assert (num_missing <= MAX_RECONCILE_ENTRIES); + GNUNET_assert (num_stale <= MAX_RECONCILE_ENTRIES); + brs = GNUNET_new (struct BlockReconcileState); + brs->sync_url = sync_url; + brs->account_reference = account_reference; + brs->http_status = http_status; + brs->expected_total_blocks = expected_total_blocks; + if (NULL != prefix) + { + brs->prefix = GNUNET_new (uint32_t); + *brs->prefix = *prefix; + } + else + brs->prefix = NULL; + brs->num_local = num_local; + if (0 < num_local) + { + GNUNET_memcpy (brs->local_nonces, + local_nonces, + num_local * sizeof (struct SYNC_BlockNonce)); + GNUNET_memcpy (brs->local_hashes, + local_hashes, + num_local * sizeof (struct GNUNET_HashCode)); + } + brs->num_missing = num_missing; + if (0 < num_missing) + { + GNUNET_memcpy (brs->missing_nonces, + missing_nonces, + num_missing * sizeof (struct SYNC_BlockNonce)); + GNUNET_memcpy (brs->missing_hashes, + missing_hashes, + num_missing * sizeof (struct GNUNET_HashCode)); + } + brs->num_stale = num_stale; + if (0 < num_stale) + { + GNUNET_memcpy (brs->stale_nonces, + stale_nonces, + num_stale * sizeof (struct SYNC_BlockNonce)); + GNUNET_memcpy (brs->stale_hashes, + stale_hashes, + num_stale * sizeof (struct GNUNET_HashCode)); + } + { + struct TALER_TESTING_Command cmd = { + .cls = brs, + .label = label, + .run = &reconcile_run, + .cleanup = &reconcile_cleanup + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_block_reconcile.c */ +\ No newline at end of file diff --git a/src/util/meson.build b/src/util/meson.build @@ -10,7 +10,7 @@ configure_file( libsyncutil = library( 'syncutil', - ['os_installation.c', 'sync_signatures.c', 'sync_block.c', 'sync_object.c'], + ['os_installation.c', 'sync_signatures.c', 'sync_block.c', 'sync_object.c', 'sync_ibf.c'], soversion: solibversions['libsyncutil']['soversion'], version: solibversions['libsyncutil']['soversion'], install_rpath: rpath_option, @@ -66,3 +66,27 @@ test( ) +test_sync_ibf = executable( + 'test_sync_ibf', + ['test_sync_ibf.c'], + install_rpath: rpath_option, + dependencies: [ + libsyncutil_dep, + talerutil_dep, + gnunetutil_dep, + gnunetjson_dep, + json_dep, + ], + include_directories: [incdir, configuration_inc], + install: false, +) +# Not in 'installcheck', which the default test setup excludes: this +# test needs neither an installed tree nor a database. +test( + 'test_sync_ibf', + test_sync_ibf, + workdir: meson.current_build_dir(), + suite: ['util'], +) + + diff --git a/src/util/sync_block.c b/src/util/sync_block.c @@ -40,6 +40,8 @@ SYNC_block_entry_parse ( json_t *input, struct SYNC_BlockListEntry *entry) { + bool missing_prev; + bool missing_next; struct GNUNET_JSON_Specification espec[] = { GNUNET_JSON_spec_fixed_auto ("nonce", &entry->nonce), @@ -51,11 +53,11 @@ SYNC_block_entry_parse ( GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_fixed_auto ("prev_nonce", &entry->prev_nonce), - &entry->have_prev_nonce), + &missing_prev), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_fixed_auto ("next_nonce", &entry->next_nonce), - &entry->have_next_nonce), + &missing_next), GNUNET_JSON_spec_fixed_auto ("upload_sig", &entry->upload_sig), GNUNET_JSON_spec_fixed_auto ("old_hash", @@ -80,6 +82,10 @@ SYNC_block_entry_parse ( ename); return GNUNET_SYSERR; } + /* The optional-field tracking flag reports whether the field is + *missing*; the entry records the inverse. */ + entry->have_prev_nonce = ! missing_prev; + entry->have_next_nonce = ! missing_next; return GNUNET_OK; } diff --git a/src/util/sync_ibf.c b/src/util/sync_ibf.c @@ -0,0 +1,543 @@ +/* + 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 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file util/sync_ibf.c + * @brief invertible bloom filter for backup reconciliation (DD 92) + * @author Iván Ávalos + */ + +/** + * The filter is a set of @a size buckets, each holding: + * + * +--------------------------+ + * | Counter (2 byte) | + * +--------------------------+ + * | Key sum (88 byte) | XOR of the elements' keys + * +--------------------------+ + * | Key hash sum (8 byte) | XOR of the elements' key hashes + * +--------------------------+ + * + * Testing membership is not possible (unlike a plain bloom filter), + * but the filter is invertible: subtracting the filters of two sets + * yields the filter of their symmetric difference, and peeling pure + * buckets decodes the differing elements one by one. This is what + * makes targeted pulls possible: given the server's filter and its + * own, a wallet can enumerate exactly the blocks that were added, + * rewritten or deleted since it last looked. + * + * Bucket indices derive from successive 32-bit words of the SHA-512 + * over element + mixing prefix, re-hashing when the words run out + * (as in GNUnet's bloom filters); duplicate indices are skipped, as + * in GNUnet's invertible-bloom-filter implementation (ibf.c in the + * SET service). + */ +#include "platform.h" +#include <gnunet/gnunet_util_lib.h> +#include "sync/sync_ibf.h" + + +/** + * The filter. + */ +struct SYNC_IBF +{ + /** + * Number of buckets. + */ + uint32_t size; + + /** + * Number of hash functions per element. + */ + unsigned int hash_num; + + /** + * Mixing salt hashed with every element. + */ + uint32_t prefix; + + /** + * Per-bucket counters (number of insertions). + */ + int16_t *count; + + /** + * Per-bucket XOR sums of the element keys. + */ + unsigned char *key_sum; + + /** + * Per-bucket XOR sums of the element key hashes. + */ + unsigned char *key_hash_sum; +}; + + +/** + * Stream of 32-bit words over a hash chain: the words of the mingled + * hash, followed by the words of its hash, and so on, enough for any + * number of distinct bucket indices. + */ +struct WordStream +{ + /** + * Current hash of the chain. + */ + unsigned char hash[64]; + + /** + * Next word to read from @e hash. + */ + unsigned int slot; +}; + + +/** + * Start the word stream over SHA-512(element || prefix). + * + * @param ws stream to initialize + * @param key element to hash + * @param prefix mixing salt (network byte order value) + */ +static void +ws_init (struct WordStream *ws, + const struct SYNC_IBFKey *key, + uint32_t prefix) +{ + unsigned char input[SYNC_IBF_KEY_SIZE + 4]; + uint32_t p = htonl (prefix); + + memcpy (input, + key, + SYNC_IBF_KEY_SIZE); + memcpy (input + SYNC_IBF_KEY_SIZE, + &p, + sizeof (p)); + GNUNET_CRYPTO_hash (input, + sizeof (input), + (struct GNUNET_HashCode *) ws->hash); + ws->slot = 0; +} + + +/** + * Read the next word of the stream. + * + * @param ws stream to read from + * @return next 32-bit word, in host byte order + */ +static uint32_t +ws_next (struct WordStream *ws) +{ + uint32_t w; + + if (ws->slot >= (sizeof (ws->hash) / sizeof (uint32_t))) + { + GNUNET_CRYPTO_hash (ws->hash, + sizeof (ws->hash), + (struct GNUNET_HashCode *) ws->hash); + ws->slot = 0; + } + memcpy (&w, + ws->hash + ws->slot * sizeof (w), + sizeof (w)); + ws->slot++; + return ntohl (w); +} + + +/** + * Store the bucket indices of an element in @a dst, one per hash + * function, skipping duplicates. + * + * @param ibf filter the element maps into (for size and hash_num) + * @param key element + * @param dst array of @a hash_num bucket indices + */ +static void +ibf_get_indices (const struct SYNC_IBF *ibf, + const struct SYNC_IBFKey *key, + int *dst) +{ + struct WordStream ws; + uint32_t filled = 0; + + ws_init (&ws, + key, + ibf->prefix); + while (filled < ibf->hash_num) + { + uint32_t bucket = ws_next (&ws) % ibf->size; + bool dup = false; + + for (uint32_t j = 0; j < filled; j++) + { + if (dst[j] == (int) bucket) + { + dup = true; + break; + } + } + if (! dup) + dst[filled++] = bucket; + } +} + + +/** + * Compute the key hash of an element: the first 8 bytes of SHA-512 + * over the key. + * + * @param key element + * @param[out] kh where to store the key hash + */ +static void +ibf_key_hash (const struct SYNC_IBFKey *key, + unsigned char kh[SYNC_IBF_KEY_HASH_SIZE]) +{ + struct GNUNET_HashCode h; + + GNUNET_CRYPTO_hash (key, + SYNC_IBF_KEY_SIZE, + &h); + memcpy (kh, + &h, + SYNC_IBF_KEY_HASH_SIZE); +} + + +/** + * XOR @a size bytes of @a src into @a dst. + */ +static void +xor_into (unsigned char *dst, + const unsigned char *src, + size_t size) +{ + for (size_t i = 0; i < size; i++) + dst[i] ^= src[i]; +} + + +/** + * Insert an element with sign @a side (+1 insert, -1 remove): the + * counterpart of inserting it on the opposite side peels it out. + * + * @param ibf filter to manipulate + * @param key element + * @param buckets its bucket indices + * @param side +1 or -1 + */ +static void +ibf_insert_into (struct SYNC_IBF *ibf, + const struct SYNC_IBFKey *key, + const int *buckets, + int16_t side) +{ + unsigned char kh[SYNC_IBF_KEY_HASH_SIZE]; + + ibf_key_hash (key, + kh); + for (unsigned int i = 0; i < ibf->hash_num; i++) + { + const int bucket = buckets[i]; + + ibf->count[bucket] += side; + xor_into (ibf->key_sum + ((size_t) bucket) * SYNC_IBF_KEY_SIZE, + (const unsigned char *) key, + SYNC_IBF_KEY_SIZE); + xor_into (ibf->key_hash_sum + ((size_t) bucket) * SYNC_IBF_KEY_HASH_SIZE, + kh, + SYNC_IBF_KEY_HASH_SIZE); + } +} + + +uint32_t +SYNC_ibf_compute_bucket_count (uint32_t block_count) +{ + uint32_t size = SYNC_IBF_MIN_BUCKETS; + + if (block_count > SYNC_IBF_MAX_BUCKETS) + return SYNC_IBF_MAX_BUCKETS; + while ( (size < SYNC_IBF_MAX_BUCKETS) && + (size < block_count) ) + size *= 2; + return size; +} + + +struct SYNC_IBF * +SYNC_ibf_create (uint32_t bucket_count, + unsigned int hash_num, + uint32_t prefix) +{ + struct SYNC_IBF *ibf; + + if ( (0 == bucket_count) || + (0 == hash_num) ) + return NULL; + ibf = GNUNET_new (struct SYNC_IBF); + ibf->size = bucket_count; + ibf->hash_num = hash_num; + ibf->prefix = prefix; + ibf->count = GNUNET_malloc (bucket_count * sizeof (int16_t)); + ibf->key_sum = GNUNET_malloc ((size_t) bucket_count * SYNC_IBF_KEY_SIZE); + ibf->key_hash_sum = GNUNET_malloc ((size_t) bucket_count + * SYNC_IBF_KEY_HASH_SIZE); + return ibf; +} + + +struct SYNC_IBF * +SYNC_ibf_deserialize (const void *data, + uint32_t bucket_count, + unsigned int hash_num, + uint32_t prefix) +{ + struct SYNC_IBF *ibf; + + if ( (NULL == data) || + (0 == bucket_count) || + (0 == hash_num) ) + return NULL; + ibf = SYNC_ibf_create (bucket_count, + hash_num, + prefix); + if (NULL == ibf) + return NULL; + for (uint32_t i = 0; i < bucket_count; i++) + { + const unsigned char *b = ((const unsigned char *) data) + + ((size_t) i) * SYNC_IBF_BUCKET_SIZE; + + memcpy (&ibf->count[i], + b, + sizeof (int16_t)); + ibf->count[i] = ntohs (ibf->count[i]); + memcpy (ibf->key_sum + ((size_t) i) * SYNC_IBF_KEY_SIZE, + b + 2, + SYNC_IBF_KEY_SIZE); + memcpy (ibf->key_hash_sum + ((size_t) i) * SYNC_IBF_KEY_HASH_SIZE, + b + 2 + SYNC_IBF_KEY_SIZE, + SYNC_IBF_KEY_HASH_SIZE); + } + return ibf; +} + + +size_t +SYNC_ibf_get_serialized_size (const struct SYNC_IBF *ibf) +{ + if (NULL == ibf) + return 0; + return ((size_t) ibf->size) * SYNC_IBF_BUCKET_SIZE; +} + + +void +SYNC_ibf_serialize (const struct SYNC_IBF *ibf, + void *data) +{ + for (uint32_t i = 0; i < ibf->size; i++) + { + unsigned char *b = ((unsigned char *) data) + + ((size_t) i) * SYNC_IBF_BUCKET_SIZE; + int16_t count = htons (ibf->count[i]); + + memcpy (b, + &count, + sizeof (count)); + memcpy (b + 2, + ibf->key_sum + ((size_t) i) * SYNC_IBF_KEY_SIZE, + SYNC_IBF_KEY_SIZE); + memcpy (b + 2 + SYNC_IBF_KEY_SIZE, + ibf->key_hash_sum + ((size_t) i) * SYNC_IBF_KEY_HASH_SIZE, + SYNC_IBF_KEY_HASH_SIZE); + } +} + + +void +SYNC_ibf_destroy (struct SYNC_IBF *ibf) +{ + if (NULL == ibf) + return; + GNUNET_free (ibf->key_hash_sum); + GNUNET_free (ibf->key_sum); + GNUNET_free (ibf->count); + GNUNET_free (ibf); +} + + +void +SYNC_ibf_insert (struct SYNC_IBF *ibf, + const struct SYNC_IBFKey *key) +{ + int buckets[ibf->hash_num]; + + GNUNET_assert (ibf->hash_num <= ibf->size); + ibf_get_indices (ibf, + key, + buckets); + ibf_insert_into (ibf, + key, + buckets, + 1); +} + + +void +SYNC_ibf_remove (struct SYNC_IBF *ibf, + const struct SYNC_IBFKey *key) +{ + int buckets[ibf->hash_num]; + + GNUNET_assert (ibf->hash_num <= ibf->size); + ibf_get_indices (ibf, + key, + buckets); + ibf_insert_into (ibf, + key, + buckets, + -1); +} + + +void +SYNC_ibf_subtract (struct SYNC_IBF *ibf1, + const struct SYNC_IBF *ibf2) +{ + GNUNET_assert (ibf1->size == ibf2->size); + GNUNET_assert (ibf1->hash_num == ibf2->hash_num); + for (uint32_t i = 0; i < ibf1->size; i++) + { + ibf1->count[i] -= ibf2->count[i]; + xor_into (ibf1->key_sum + ((size_t) i) * SYNC_IBF_KEY_SIZE, + ibf2->key_sum + ((size_t) i) * SYNC_IBF_KEY_SIZE, + SYNC_IBF_KEY_SIZE); + xor_into (ibf1->key_hash_sum + + ((size_t) i) * SYNC_IBF_KEY_HASH_SIZE, + ibf2->key_hash_sum + + ((size_t) i) * SYNC_IBF_KEY_HASH_SIZE, + SYNC_IBF_KEY_HASH_SIZE); + } +} + + +/** + * Test if the filter is empty, i.e. all counts and sums are zero. + * + * @param ibf filter to test + * @return #GNUNET_YES if empty + */ +static int +ibf_is_empty (const struct SYNC_IBF *ibf) +{ + for (uint32_t i = 0; i < ibf->size; i++) + { + if (0 != ibf->count[i]) + return GNUNET_NO; + for (unsigned int j = 0; j < SYNC_IBF_KEY_HASH_SIZE; j++) + if (0 != ibf->key_hash_sum[((size_t) i) * SYNC_IBF_KEY_HASH_SIZE + j]) + return GNUNET_NO; + for (unsigned int j = 0; j < SYNC_IBF_KEY_SIZE; j++) + if (0 != ibf->key_sum[((size_t) i) * SYNC_IBF_KEY_SIZE + j]) + return GNUNET_NO; + } + return GNUNET_YES; +} + + +enum GNUNET_GenericReturnValue +SYNC_ibf_decode (struct SYNC_IBF *ibf, + int16_t *ret_side, + struct SYNC_IBFKey *ret_key) +{ + for (uint32_t i = 0; i < ibf->size; i++) + { + struct SYNC_IBFKey peeled; + unsigned char kh[SYNC_IBF_KEY_HASH_SIZE]; + int buckets[ibf->hash_num]; + + /* only pure buckets can be decoded */ + if ( (1 != ibf->count[i]) && + (-1 != ibf->count[i]) ) + continue; + + /* the key sum of a pure bucket is the key: work on a copy, since + peeling XORs it back out of the bucket this key came from */ + memcpy (&peeled, + ibf->key_sum + ((size_t) i) * SYNC_IBF_KEY_SIZE, + SYNC_IBF_KEY_SIZE); + + /* check the key's hash against the bucket's hash sum */ + ibf_key_hash (&peeled, + kh); + { + bool kh_ok = true; + + for (unsigned int j = 0; j < SYNC_IBF_KEY_HASH_SIZE; j++) + if (kh[j] != + ibf->key_hash_sum[((size_t) i) * SYNC_IBF_KEY_HASH_SIZE + j]) + { + kh_ok = false; + break; + } + if (! kh_ok) + continue; + } + + /* the key must map to this bucket itself */ + ibf_get_indices (ibf, + &peeled, + buckets); + { + bool hit = false; + + for (unsigned int j = 0; j < ibf->hash_num; j++) + { + if (buckets[j] == (int) i) + { + hit = true; + break; + } + } + if (! hit) + continue; + } + + if (NULL != ret_side) + *ret_side = ibf->count[i]; + if (NULL != ret_key) + *ret_key = peeled; + + /* insert on the opposite side, peeling the element out */ + ibf_insert_into (ibf, + &peeled, + buckets, + -ibf->count[i]); + + return GNUNET_YES; + } + + if (GNUNET_YES == ibf_is_empty (ibf)) + return GNUNET_NO; + return GNUNET_SYSERR; +} + + +/* end of sync_ibf.c */ +\ No newline at end of file diff --git a/src/util/test_sync_ibf.c b/src/util/test_sync_ibf.c @@ -0,0 +1,283 @@ +/* + 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 util/test_sync_ibf.c + * @brief tests for the sync invertible bloom filter (DD 92) + * @author Iván Ávalos + */ +#include "platform.h" +#include <gnunet/gnunet_util_lib.h> +#include "sync/sync_ibf.h" + + +#define FAILIF(cond) \ + do { \ + if (! (cond)) break; \ + GNUNET_break (0); \ + return 1; \ + } while (0) + + +/** + * Deterministic element: nonce and hash filled with distinct bytes + * derived from @a i. + * + * @param i element number + * @param[out] key set to the element + */ +static void +key_of (unsigned int i, + struct SYNC_IBFKey *key) +{ + memset (key, + i + 1, + sizeof (*key)); + key->nonce[0] = (unsigned char) i; + key->hash[0] = (unsigned char) (i >> 8); +} + + +/** + * Insert and remove leave the filter empty again. + * + * @return 0 on success + */ +static int +test_insert_remove (void) +{ + struct SYNC_IBF *ibf; + struct SYNC_IBFKey key; + size_t size; + void *data; + + ibf = SYNC_ibf_create (SYNC_IBF_MIN_BUCKETS, + SYNC_IBF_HASH_NUM, + 0); + FAILIF (NULL == ibf); + for (unsigned int i = 0; i < 100; i++) + { + key_of (i, + &key); + SYNC_ibf_insert (ibf, + &key); + } + for (unsigned int i = 0; i < 100; i++) + { + key_of (i, + &key); + SYNC_ibf_remove (ibf, + &key); + } + size = SYNC_ibf_get_serialized_size (ibf); + data = GNUNET_malloc (size); + SYNC_ibf_serialize (ibf, + data); + for (size_t i = 0; i < size; i++) + if (0 != ((const unsigned char *) data)[i]) + { + GNUNET_break (0); + GNUNET_free (data); + SYNC_ibf_destroy (ibf); + return 1; + } + GNUNET_free (data); + SYNC_ibf_destroy (ibf); + return 0; +} + + +/** + * The symmetric difference of two filters decodes to exactly the + * differing elements, with the correct side. + * + * Sets: A = {0..9} union {20..29}, B = {10..19} union {20..29}. + * After subtracting B from A, the difference holds 0..9 with side +1 + * and 10..19 with side -1; 20..29 cancel out. + * + * @return 0 on success + */ +static int +test_decode_difference (void) +{ + struct SYNC_IBF *ibf_a = SYNC_ibf_create (SYNC_IBF_MIN_BUCKETS, + SYNC_IBF_HASH_NUM, + 0); + struct SYNC_IBF *ibf_b = SYNC_ibf_create (SYNC_IBF_MIN_BUCKETS, + SYNC_IBF_HASH_NUM, + 0); + struct SYNC_IBFKey key; + + FAILIF ( (NULL == ibf_a) || + (NULL == ibf_b) ); + for (unsigned int i = 0; i < 30; i++) + { + key_of (i, + &key); + if ( (i < 10) || + (i >= 20) ) + SYNC_ibf_insert (ibf_a, + &key); + if ( (i >= 10) ) + SYNC_ibf_insert (ibf_b, + &key); + } + + SYNC_ibf_subtract (ibf_a, + ibf_b); + { + bool have[30] = { false }; + int16_t side[30] = { 0 }; + + for (;;) + { + enum GNUNET_GenericReturnValue res; + int16_t got_side; + struct SYNC_IBFKey got_key; + + res = SYNC_ibf_decode (ibf_a, + &got_side, + &got_key); + if (GNUNET_YES != res) + { + FAILIF (GNUNET_NO != res); + break; + } + { + const unsigned int i = (unsigned int) got_key.nonce[0]; + + FAILIF (i >= 30); + FAILIF ((got_side != 1) && (got_side != -1)); + FAILIF (have[i]); + have[i] = true; + side[i] = got_side; + } + } + for (unsigned int i = 0; i < 30; i++) + { + if (i < 10) + FAILIF (! have[i] || (1 != side[i])); + else if (i < 20) + FAILIF (! have[i] || (-1 != side[i])); + else + FAILIF (have[i]); + } + } + SYNC_ibf_destroy (ibf_b); + SYNC_ibf_destroy (ibf_a); + return 0; +} + + +/** + * The serialized form round-trips, and mismatching parameters are + * rejected. + * + * @return 0 on success + */ +static int +test_serialize (void) +{ + struct SYNC_IBF *ibf; + struct SYNC_IBF *ibf2; + struct SYNC_IBFKey key; + struct SYNC_IBFKey got_key; + size_t size; + void *data; + + ibf = SYNC_ibf_create (SYNC_IBF_MIN_BUCKETS, + SYNC_IBF_HASH_NUM, + 42); + FAILIF (NULL == ibf); + for (unsigned int i = 0; i < 20; i++) + { + key_of (i, + &key); + SYNC_ibf_insert (ibf, + &key); + } + size = SYNC_ibf_get_serialized_size (ibf); + FAILIF (size != ((size_t) SYNC_IBF_MIN_BUCKETS) * SYNC_IBF_BUCKET_SIZE); + data = GNUNET_malloc (size); + SYNC_ibf_serialize (ibf, + data); + ibf2 = SYNC_ibf_deserialize (data, + SYNC_IBF_MIN_BUCKETS, + SYNC_IBF_HASH_NUM, + 42); + FAILIF (NULL == ibf2); + FAILIF (NULL != + SYNC_ibf_deserialize (data, + 0, + SYNC_IBF_HASH_NUM, + 42)); + { + int16_t side; + unsigned int got = 0; + + for (;;) + { + enum GNUNET_GenericReturnValue res; + + res = SYNC_ibf_decode (ibf2, + &side, + &got_key); + if (GNUNET_YES != res) + { + FAILIF (GNUNET_NO != res); + break; + } + got++; + } + FAILIF (got != 20); + } + SYNC_ibf_destroy (ibf2); + GNUNET_free (data); + SYNC_ibf_destroy (ibf); + return 0; +} + + +/** + * Bucket counts are powers of two within the bounds. + * + * @return 0 on success + */ +static int +test_compute_bucket_count (void) +{ + FAILIF (SYNC_ibf_compute_bucket_count (0) != SYNC_IBF_MIN_BUCKETS); + FAILIF (SYNC_ibf_compute_bucket_count (1) != SYNC_IBF_MIN_BUCKETS); + FAILIF (SYNC_ibf_compute_bucket_count (1000) != 1024); + FAILIF (SYNC_ibf_compute_bucket_count (100000) != SYNC_IBF_MAX_BUCKETS); + return 0; +} + + +int +main (int argc, + char **argv) +{ + (void) argc; + (void) argv; + FAILIF (0 != test_insert_remove ()); + FAILIF (0 != test_decode_difference ()); + FAILIF (0 != test_serialize ()); + FAILIF (0 != test_compute_bucket_count ()); + return 0; +} + + +/* end of test_sync_ibf.c */ +\ No newline at end of file