merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

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:
Msrc/backenddb/insert_issued_token.sql | 7++++++-
Msrc/backenddb/sql-schema/merchant-0043.sql | 19+++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 1+
Msrc/backenddb/test_merchantdb.c | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 ());