commit e36697ce0c17ee364225901697700b70f2ef6a08
parent 6b0297d66a1a4e12085bf5209a66a273e9a58d39
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 5 Aug 2026 22:31:03 +0200
add missing unique constraint on merchant_issued_tokens: do not store the same blind_sig twice for the same family and contract
Diffstat:
4 files changed, 408 insertions(+), 1 deletion(-)
diff --git a/src/backenddb/insert_issued_token.sql b/src/backenddb/insert_issued_token.sql
@@ -55,7 +55,12 @@ INSERT INTO merchant_issued_tokens
(my_tfk_serial
,in_h_contract_terms
,in_blind_sig)
- ON CONFLICT DO NOTHING;
+ -- Naming the arbiter explicitly is intentional: if the UNIQUE
+ -- constraint were missing, this fails loudly at plan time instead
+ -- of silently degenerating into an INSERT that always succeeds.
+ ON CONFLICT (token_family_key_serial
+ ,h_contract_terms
+ ,blind_sig) DO NOTHING;
IF NOT FOUND
THEN
diff --git a/src/backenddb/sql-schema/merchant-0043.sql b/src/backenddb/sql-schema/merchant-0043.sql
@@ -35,6 +35,25 @@ BEGIN
' AND f.ctid > d.ctid;', s, s);
EXECUTE format('ALTER TABLE %I.merchant_product_categories' ||
' ADD PRIMARY KEY(product_serial, category_serial);', s);
+
+ -- Add unique constraint, delete constraint-violating values first.
+ -- Without this constraint the 'ON CONFLICT' in
+ -- merchant_do_insert_issued_token had no arbiter it could ever match
+ -- (the only unique index was the primary key on the GENERATED ...
+ -- AS IDENTITY column, which the INSERT never supplies), so a replayed
+ -- issuance duplicated the row and re-incremented the 'issued' counter.
+
+ EXECUTE format('DELETE FROM %I.merchant_issued_tokens f' ||
+ ' USING %I.merchant_issued_tokens d' ||
+ ' WHERE f.token_family_key_serial = d.token_family_key_serial' ||
+ ' AND f.h_contract_terms = d.h_contract_terms' ||
+ ' AND f.blind_sig = d.blind_sig' ||
+ ' AND f.ctid > d.ctid;', s, s);
+ EXECUTE format('ALTER TABLE %I.merchant_issued_tokens' ||
+ ' ADD CONSTRAINT merchant_issued_tokens_token_key' ||
+ ' UNIQUE (token_family_key_serial, h_contract_terms,' ||
+ ' blind_sig);', s);
+
END
$OUTER$;
diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build
@@ -121,6 +121,7 @@ generated_sql = [
['merchant-0041.sql'],
['merchant-0042.sql'],
['merchant-0043.sql'],
+ ['merchant-0044.sql'],
]
foreach g : generated_sql
diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c
@@ -60,6 +60,11 @@
#include "merchant-database/insert_transfer_details.h"
#include "merchant-database/insert_webhook.h"
#include "merchant-database/insert_inventory_lock.h"
+#include "merchant-database/insert_issued_token.h"
+#include "merchant-database/insert_used_token.h"
+#include "merchant-database/insert_token_family.h"
+#include "merchant-database/insert_token_family_key.h"
+#include "merchant-database/get_token_family.h"
#include "merchant-database/get_account_serial.h"
#include "merchant-database/get_contract_terms.h"
#include "merchant-database/get_contract_terms_status.h"
@@ -1638,6 +1643,382 @@ test_products (void)
}
+/* ********** Tokens ********** */
+
+
+/**
+ * Container for the data used by the token tests.
+ */
+struct TestTokens_Closure
+{
+ /**
+ * The instance to use for this test.
+ */
+ struct InstanceData instance;
+
+ /**
+ * Details of the token family we operate on.
+ */
+ struct TALER_MERCHANTDB_TokenFamilyDetails family;
+
+ /**
+ * Public key of the token family key.
+ */
+ struct TALER_TokenIssuePublicKey pub;
+
+ /**
+ * Private key of the token family key.
+ */
+ struct TALER_TokenIssuePrivateKey priv;
+
+ /**
+ * Hash of @e pub, identifies the token family key in the DB.
+ */
+ struct TALER_TokenIssuePublicKeyHashP h_pub;
+
+ /**
+ * Hash of the contract the tokens are issued for.
+ */
+ struct TALER_PrivateContractHashP h_contract_terms;
+};
+
+
+/**
+ * Fabricate a blinded token issue signature. Its value is never
+ * inspected by the database, so a random CS answer is good enough; we
+ * only care that identical inputs yield identical bytes.
+ *
+ * @param[out] bs storage for the blinded signature
+ * @param[out] sig set to point to @a bs
+ */
+static void
+make_blinded_token_sig (struct GNUNET_CRYPTO_BlindedSignature *bs,
+ struct TALER_BlindedTokenIssueSignature *sig)
+{
+ memset (bs,
+ 0,
+ sizeof (*bs));
+ bs->cipher = GNUNET_CRYPTO_BSA_CS;
+ bs->rc = 1;
+ GNUNET_CRYPTO_random_block (&bs->details.blinded_cs_answer,
+ sizeof (bs->details.blinded_cs_answer));
+ sig->signature = bs;
+}
+
+
+/**
+ * Fabricate an (unblinded) token issue signature.
+ *
+ * @param[out] ub storage for the unblinded signature
+ * @param[out] sig set to point to @a ub
+ */
+static void
+make_token_issue_sig (struct GNUNET_CRYPTO_UnblindedSignature *ub,
+ struct TALER_TokenIssueSignature *sig)
+{
+ memset (ub,
+ 0,
+ sizeof (*ub));
+ ub->cipher = GNUNET_CRYPTO_BSA_CS;
+ ub->rc = 1;
+ GNUNET_CRYPTO_random_block (&ub->details.cs_signature,
+ sizeof (ub->details.cs_signature));
+ sig->signature = ub;
+}
+
+
+/**
+ * Checks the issuance/spending counters of the token family.
+ *
+ * @param cls the test data.
+ * @param expected_issued number of issued tokens we expect.
+ * @param expected_used number of spent tokens we expect.
+ * @return 0 when successful, 1 otherwise.
+ */
+static int
+test_token_family_counters (const struct TestTokens_Closure *cls,
+ uint64_t expected_issued,
+ uint64_t expected_used)
+{
+ struct TALER_MERCHANTDB_TokenFamilyDetails details;
+ int ret = 0;
+
+ memset (&details,
+ 0,
+ sizeof (details));
+ TEST_SET_INSTANCE (cls->instance.instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_COND_RET_ON_FAIL (
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_get_token_family (pg,
+ cls->instance.instance.id,
+ cls->family.slug,
+ &details),
+ "Lookup of token family failed\n");
+ if ( (expected_issued != details.issued) ||
+ (expected_used != details.used) )
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Token family counters are issued=%llu/used=%llu,"
+ " expected issued=%llu/used=%llu\n",
+ (unsigned long long) details.issued,
+ (unsigned long long) details.used,
+ (unsigned long long) expected_issued,
+ (unsigned long long) expected_used);
+ ret = 1;
+ }
+ TALER_MERCHANTDB_token_family_details_free (&details);
+ return ret;
+}
+
+
+/**
+ * Tests inserting an issued token.
+ *
+ * @param cls the test data.
+ * @param blind_sig the blinded signature of the issued token.
+ * @param expected_result the result we expect the db to return.
+ * @return 0 when successful, 1 otherwise.
+ */
+static int
+test_insert_issued_token (const struct TestTokens_Closure *cls,
+ const struct TALER_BlindedTokenIssueSignature *
+ blind_sig,
+ enum GNUNET_DB_QueryStatus expected_result)
+{
+ TEST_SET_INSTANCE (cls->instance.instance.id,
+ expected_result);
+ TEST_COND_RET_ON_FAIL (
+ expected_result ==
+ TALER_MERCHANTDB_insert_issued_token (pg,
+ &cls->h_contract_terms,
+ &cls->h_pub,
+ blind_sig),
+ "Insert issued token returned unexpected status\n");
+ return 0;
+}
+
+
+/**
+ * Tests inserting a spent token.
+ *
+ * @param cls the test data.
+ * @param use_pub public key of the token being spent.
+ * @param use_sig signature made with the token use key.
+ * @param issue_sig signature of the merchant over the token.
+ * @param expected_result the result we expect the db to return.
+ * @return 0 when successful, 1 otherwise.
+ */
+static int
+test_insert_used_token (const struct TestTokens_Closure *cls,
+ const struct TALER_TokenUsePublicKeyP *use_pub,
+ const struct TALER_TokenUseSignatureP *use_sig,
+ const struct TALER_TokenIssueSignature *issue_sig,
+ enum GNUNET_DB_QueryStatus expected_result)
+{
+ TEST_SET_INSTANCE (cls->instance.instance.id,
+ expected_result);
+ TEST_COND_RET_ON_FAIL (
+ expected_result ==
+ TALER_MERCHANTDB_insert_used_token (pg,
+ &cls->h_contract_terms,
+ &cls->h_pub,
+ use_pub,
+ use_sig,
+ issue_sig),
+ "Insert used token returned unexpected status\n");
+ return 0;
+}
+
+
+/**
+ * Sets up the data structures used in the token tests.
+ *
+ * @param cls the closure to fill with test data.
+ */
+static void
+pre_test_tokens (struct TestTokens_Closure *cls)
+{
+ make_instance ("test_inst_tokens",
+ &cls->instance);
+ memset (&cls->family,
+ 0,
+ sizeof (cls->family));
+ cls->family.slug = (char *) "test_tokens_family";
+ cls->family.name = (char *) "Test token family";
+ cls->family.description = (char *) "This is a test token family";
+ cls->family.description_i18n = json_object ();
+ GNUNET_assert (NULL != cls->family.description_i18n);
+ cls->family.valid_after = GNUNET_TIME_timestamp_get ();
+ cls->family.valid_before
+ = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_YEARS);
+ cls->family.duration = GNUNET_TIME_UNIT_DAYS;
+ cls->family.validity_granularity = GNUNET_TIME_UNIT_DAYS;
+ cls->family.start_offset = GNUNET_TIME_UNIT_ZERO;
+ cls->family.kind = TALER_MERCHANTDB_TFK_Discount;
+ GNUNET_CRYPTO_blind_sign_keys_create (&cls->priv.private_key,
+ &cls->pub.public_key,
+ GNUNET_CRYPTO_BSA_CS);
+ cls->h_pub.hash = cls->pub.public_key->pub_key_hash;
+ GNUNET_CRYPTO_random_block (&cls->h_contract_terms,
+ sizeof (cls->h_contract_terms));
+}
+
+
+/**
+ * Handles all teardown after testing.
+ *
+ * @param cls the closure containing memory to be freed.
+ */
+static void
+post_test_tokens (struct TestTokens_Closure *cls)
+{
+ GNUNET_CRYPTO_blind_sign_pub_decref (cls->pub.public_key);
+ GNUNET_CRYPTO_blind_sign_priv_decref (cls->priv.private_key);
+ json_decref (cls->family.description_i18n);
+ free_instance_data (&cls->instance);
+}
+
+
+/**
+ * Runs the tests for issued and spent tokens.
+ *
+ * @param cls the container of the test data.
+ * @return 0 on success, 1 otherwise.
+ */
+static int
+run_test_tokens (struct TestTokens_Closure *cls)
+{
+ struct GNUNET_CRYPTO_BlindedSignature bs1;
+ struct GNUNET_CRYPTO_BlindedSignature bs2;
+ struct TALER_BlindedTokenIssueSignature blind_sig1;
+ struct TALER_BlindedTokenIssueSignature blind_sig2;
+ struct GNUNET_CRYPTO_UnblindedSignature ub1;
+ struct TALER_TokenIssueSignature issue_sig;
+ struct TALER_TokenUsePublicKeyP use_pub;
+ struct TALER_TokenUseSignatureP use_sig;
+ struct TALER_TokenUseSignatureP other_sig;
+
+ make_blinded_token_sig (&bs1,
+ &blind_sig1);
+ make_blinded_token_sig (&bs2,
+ &blind_sig2);
+ make_token_issue_sig (&ub1,
+ &issue_sig);
+ GNUNET_CRYPTO_random_block (&use_pub,
+ sizeof (use_pub));
+ GNUNET_CRYPTO_random_block (&use_sig,
+ sizeof (use_sig));
+ GNUNET_CRYPTO_random_block (&other_sig,
+ sizeof (other_sig));
+ /* Set up instance, token family and token family key */
+ TEST_RET_ON_FAIL (test_insert_instance (&cls->instance,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_SET_INSTANCE (cls->instance.instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_COND_RET_ON_FAIL (
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_insert_token_family (pg,
+ cls->instance.instance.id,
+ cls->family.slug,
+ &cls->family),
+ "Insert token family failed\n");
+ TEST_SET_INSTANCE (cls->instance.instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_COND_RET_ON_FAIL (
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_insert_token_family_key (pg,
+ cls->instance.instance.id,
+ cls->family.slug,
+ &cls->pub,
+ &cls->priv,
+ cls->family.valid_before,
+ cls->family.valid_after,
+ cls->family.valid_before),
+ "Insert token family key failed\n");
+ TEST_RET_ON_FAIL (test_token_family_counters (cls,
+ 0,
+ 0));
+ /* Issuing a token bumps the 'issued' counter */
+ TEST_RET_ON_FAIL (test_insert_issued_token (
+ cls,
+ &blind_sig1,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_RET_ON_FAIL (test_token_family_counters (cls,
+ 1,
+ 0));
+ /* Re-issuing the very same token must be detected as a duplicate
+ and must NOT bump the counter a second time. */
+ TEST_RET_ON_FAIL (test_insert_issued_token (
+ cls,
+ &blind_sig1,
+ GNUNET_DB_STATUS_SUCCESS_NO_RESULTS));
+ TEST_RET_ON_FAIL (test_token_family_counters (cls,
+ 1,
+ 0));
+ /* A different blind signature is a different token */
+ TEST_RET_ON_FAIL (test_insert_issued_token (
+ cls,
+ &blind_sig2,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_RET_ON_FAIL (test_token_family_counters (cls,
+ 2,
+ 0));
+ /* Spending a token bumps the 'used' counter */
+ TEST_RET_ON_FAIL (test_insert_used_token (
+ cls,
+ &use_pub,
+ &use_sig,
+ &issue_sig,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_RET_ON_FAIL (test_token_family_counters (cls,
+ 2,
+ 1));
+ /* Replaying the exact same spend is idempotent: reported as success,
+ but the 'used' counter must not move a second time. */
+ TEST_RET_ON_FAIL (test_insert_used_token (
+ cls,
+ &use_pub,
+ &use_sig,
+ &issue_sig,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_RET_ON_FAIL (test_token_family_counters (cls,
+ 2,
+ 1));
+ /* Reusing the same token key with a different signature is
+ double-spending and must be refused */
+ TEST_RET_ON_FAIL (test_insert_used_token (
+ cls,
+ &use_pub,
+ &other_sig,
+ &issue_sig,
+ GNUNET_DB_STATUS_SUCCESS_NO_RESULTS));
+ TEST_RET_ON_FAIL (test_token_family_counters (cls,
+ 2,
+ 1));
+ return 0;
+}
+
+
+/**
+ * Takes care of token testing.
+ *
+ * @return 0 on success, 1 otherwise.
+ */
+static int
+test_tokens (void)
+{
+ struct TestTokens_Closure test_cls;
+ int test_result;
+
+ pre_test_tokens (&test_cls);
+ test_result = run_test_tokens (&test_cls);
+ post_test_tokens (&test_cls);
+ return test_result;
+}
+
+
/* ********** Orders ********** */
@@ -7950,6 +8331,7 @@ run_tests (void)
{
TEST_RET_ON_FAIL (test_instances ());
TEST_RET_ON_FAIL (test_products ());
+ TEST_RET_ON_FAIL (test_tokens ());
TEST_RET_ON_FAIL (test_orders ());
TEST_RET_ON_FAIL (test_deposits ());
TEST_RET_ON_FAIL (test_transfers ());