merchant

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

commit 8e1875a48837c06ce1085fb38ecfab1037b1b6b0
parent 2b714201944476486e91965f63b4e2cc8b18b427
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Sun,  2 Aug 2026 18:40:49 +0200

dd97

Diffstat:
Mmeson.build | 6+++---
Msrc/backend/taler-merchant-httpd_get-config.c | 2+-
Msrc/backend/taler-merchant-httpd_get-private-otp-devices-DEVICE_ID.c | 44++++++++++++++++++++++++++++++++++++--------
Msrc/backend/taler-merchant-httpd_patch-private-otp-devices-DEVICE_ID.c | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/backend/taler-merchant-httpd_post-orders-ORDER_ID-pay.c | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/backend/taler-merchant-httpd_post-private-orders.c | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/backend/taler-merchant-httpd_post-private-otp-devices.c | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/backend/taler-merchant-httpd_post-templates-TEMPLATE_ID.c | 26++++++++++++++++++++++++++
Msrc/backenddb/get_contract_terms_pos.c | 32+++++++++++++++++++++++++++++++-
Msrc/backenddb/get_otp_device.c | 29+++++++++++++++++++++++++++--
Msrc/backenddb/insert_contract_terms.c | 4+++-
Msrc/backenddb/insert_order.c | 10++++++++--
Msrc/backenddb/insert_otp_device.c | 10++++++++--
Msrc/backenddb/sql-schema/drop.sql | 22+++++++++++++++-------
Asrc/backenddb/sql-schema/merchant-0048.sql | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 1+
Msrc/backenddb/test_merchantdb.c | 202++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/backenddb/update_otp_device.c | 13++++++++++++-
Msrc/include/merchant-database/get_contract_terms_pos.h | 8++++++--
Msrc/include/merchant-database/insert_order.h | 5++++-
Msrc/include/merchant-database/update_otp_device.h | 11+++++++----
Msrc/include/merchantdb_lib.h | 17++++++++++++++++-
Msrc/include/taler/merchant/get-private-otp-devices-DEVICE_ID.h | 8++++++++
Msrc/include/taler/merchant/post-private-orders.h | 28+++++++++++++++++++++++++++-
Msrc/include/taler/merchant/post-private-otp-devices.h | 23+++++++++++++++++++++++
Msrc/include/taler/merchant/post-templates-TEMPLATE_ID.h | 29++++++++++++++++++++++++++++-
Msrc/include/taler/taler_merchant_testing_lib.h | 23++++++++++++++++++++++-
Msrc/lib/merchant_api_get-config.c | 4++--
Msrc/lib/merchant_api_get-private-otp-devices-DEVICE_ID.c | 4++++
Msrc/lib/merchant_api_patch-private-otp-devices-DEVICE_ID.c | 1+
Msrc/lib/merchant_api_post-private-orders.c | 18++++++++++++++++++
Msrc/lib/merchant_api_post-private-otp-devices.c | 19+++++++++++++++++++
Msrc/lib/merchant_api_post-templates-TEMPLATE_ID.c | 22++++++++++++++++++++--
Msrc/testing/test_merchant_api.c | 420+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_patch_otp_device.c | 6+++++-
Msrc/testing/testing_api_cmd_pay_order.c | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_post_orders.c | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_post_otp_devices.c | 35++++++++++++++++++++++++++++++++++-
Msrc/testing/testing_api_cmd_post_using_templates.c | 44++++++++++++++++++++++++++++++++++++++++----
39 files changed, 1643 insertions(+), 109 deletions(-)

diff --git a/meson.build b/meson.build @@ -335,11 +335,11 @@ if not get_option('only-doc') libltversions = [ - ['libtalermerchant', '12:0:1'], + ['libtalermerchant', '13:0:2'], ['libtalermerchantutil', '2:0:2'], ['libtalermerchantbank', '0:1:0'], - ['libtalermerchantdb', '9:0:1'], - ['libtalermerchanttesting', '5:0:2'], + ['libtalermerchantdb', '10:0:0'], + ['libtalermerchanttesting', '6:0:3'], ] solibversions = {} diff --git a/src/backend/taler-merchant-httpd_get-config.c b/src/backend/taler-merchant-httpd_get-config.c @@ -44,7 +44,7 @@ * #MERCHANT_PROTOCOL_CURRENT and #MERCHANT_PROTOCOL_AGE in * merchant_api_get_config.c! */ -#define MERCHANT_PROTOCOL_VERSION "42:0:30" +#define MERCHANT_PROTOCOL_VERSION "43:0:31" /** diff --git a/src/backend/taler-merchant-httpd_get-private-otp-devices-DEVICE_ID.c b/src/backend/taler-merchant-httpd_get-private-otp-devices-DEVICE_ID.c @@ -79,13 +79,37 @@ TMH_private_get_otp_devices_ID (const struct TMH_RequestHandler *rh, enum MHD_Result ret; char *pos_confirmation; - pos_confirmation = (NULL == tp.otp_key) - ? NULL - : TALER_build_pos_confirmation (tp.otp_key, - tp.otp_algorithm, - &price, - my_time); - /* Note: we deliberately (by design) do not return the otp_key */ + switch (tp.otp_algorithm) + { + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + /* the confirmation is a signature over the challenge of a + specific order, so there is no per-device code to compute + here; the device is identified by otp_device_pub below */ + pos_confirmation = NULL; + break; + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + pos_confirmation = (NULL == tp.otp_key) + ? NULL + : TALER_build_pos_confirmation (tp.otp_key, + tp.otp_algorithm, + &price, + my_time); + /* Note: we deliberately (by design) do not return the otp_key */ + break; + default: + GNUNET_break (0); + GNUNET_free (tp.otp_description); + GNUNET_free (tp.otp_key); + GNUNET_free (tp.otp_device_pub); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "unknown OTP algorithm"); + } ret = TALER_MHD_REPLY_JSON_PACK ( connection, MHD_HTTP_OK, @@ -99,10 +123,14 @@ TMH_private_get_otp_devices_ID (const struct TMH_RequestHandler *rh, GNUNET_JSON_pack_uint64 ("otp_algorithm", tp.otp_algorithm), GNUNET_JSON_pack_uint64 ("otp_ctr", - tp.otp_ctr)); + tp.otp_ctr), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_string ("otp_device_pub", + tp.otp_device_pub))); GNUNET_free (pos_confirmation); GNUNET_free (tp.otp_description); GNUNET_free (tp.otp_key); + GNUNET_free (tp.otp_device_pub); return ret; } } diff --git a/src/backend/taler-merchant-httpd_patch-private-otp-devices-DEVICE_ID.c b/src/backend/taler-merchant-httpd_patch-private-otp-devices-DEVICE_ID.c @@ -27,6 +27,22 @@ #include "taler-merchant-httpd_helper.h" #include <taler/taler_json_lib.h> #include "merchant-database/update_otp_device.h" +#include "merchant-database/get_otp_device.h" + + +/** + * Does @a alg confirm payments by signing a challenge with a key pair + * generated by the backend? + * + * @param alg algorithm to check + * @return true for the challenge-signature algorithms + */ +static bool +is_challenge_alg (enum TALER_MerchantConfirmationAlgorithm alg) +{ + return ( (TALER_MCA_ECDSA_CHALLENGE == alg) || + (TALER_MCA_EDDSA_CHALLENGE == alg) ); +} enum MHD_Result @@ -37,6 +53,8 @@ TMH_private_patch_otp_devices_ID (const struct TMH_RequestHandler *rh, struct TMH_MerchantInstance *mi = hc->instance; const char *device_id = hc->infix; struct TALER_MERCHANTDB_OtpDeviceDetails tp = {0}; + uint64_t expected_serial; + enum TALER_MerchantConfirmationAlgorithm expected_algorithm; enum GNUNET_DB_QueryStatus qs; struct GNUNET_JSON_Specification spec[] = { GNUNET_JSON_spec_string ("otp_device_description", @@ -69,9 +87,82 @@ TMH_private_patch_otp_devices_ID (const struct TMH_RequestHandler *rh, : MHD_NO; } + switch (tp.otp_algorithm) + { + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + /* the key pair belongs to the backend; there is nothing here for a + client to set, and replacing it would silently invalidate every + offline verifier already configured with the public key */ + if (NULL != tp.otp_key) + { + GNUNET_break_op (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "otp_key"); + } + break; + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + break; + } + + { + struct TALER_MERCHANTDB_OtpDeviceDetails etp; + bool incompatible = false; + + qs = TALER_MERCHANTDB_get_otp_device (TMH_db, + mi->settings.id, + device_id, + &etp); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + case GNUNET_DB_STATUS_SOFT_ERROR: + GNUNET_break (0); + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "get_otp_device"); + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_OTP_DEVICE_UNKNOWN, + device_id); + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + expected_serial = etp.otp_serial; + expected_algorithm = etp.otp_algorithm; + incompatible = (etp.otp_algorithm != tp.otp_algorithm) && + (is_challenge_alg (etp.otp_algorithm) || + is_challenge_alg (tp.otp_algorithm)); + GNUNET_free (etp.otp_description); + GNUNET_free (etp.otp_key); + GNUNET_free (etp.otp_device_pub); + break; + } + if (incompatible) + { + /* Challenge keys belong to a specific algorithm and cannot be + reused with another challenge algorithm or with TOTP. Recreate + the device to change its algorithm. */ + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_PRIVATE_PATCH_OTP_DEVICES_CONFLICT, + "otp_algorithm"); + } + } + qs = TALER_MERCHANTDB_update_otp_device (TMH_db, mi->settings.id, device_id, + expected_serial, + expected_algorithm, &tp); { enum MHD_Result ret = MHD_NO; @@ -93,10 +184,12 @@ TMH_private_patch_otp_devices_ID (const struct TMH_RequestHandler *rh, "unexpected serialization problem"); break; case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + /* Validation no longer describes this device. The conditional + UPDATE left the concurrent writer's device untouched. */ ret = TALER_MHD_reply_with_error (connection, - MHD_HTTP_NOT_FOUND, - TALER_EC_MERCHANT_GENERIC_OTP_DEVICE_UNKNOWN, - device_id); + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_PRIVATE_PATCH_OTP_DEVICES_CONFLICT, + "OTP device changed during update"); break; case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: ret = TALER_MHD_reply_static (connection, diff --git a/src/backend/taler-merchant-httpd_post-orders-ORDER_ID-pay.c b/src/backend/taler-merchant-httpd_post-orders-ORDER_ID-pay.c @@ -699,6 +699,12 @@ struct PayContext char *pos_key; /** + * Challenge to sign, if @e pos_alg is a challenge-signature + * algorithm. + */ + struct TALER_PosChallengeP pos_challenge; + + /** * Serial number of this order in the database (set once we did the lookup). */ uint64_t order_serial; @@ -1319,8 +1325,8 @@ do_batch_deposits (struct ExchangeGroup *eg); */ static void defer_batch_deposit_error (struct PayContext *pc, - unsigned int response_code, - struct MHD_Response *response) + unsigned int response_code, + struct MHD_Response *response) { if (NULL == pc->response) { @@ -1423,11 +1429,11 @@ batch_deposit_cb ( { /* internal server error at exchange */ defer_batch_deposit_error (pc, - MHD_HTTP_BAD_GATEWAY, - TALER_MHD_MAKE_JSON_PACK ( - TALER_JSON_pack_ec ( - TALER_EC_MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS), - TMH_pack_exchange_reply (&dr->hr))); + MHD_HTTP_BAD_GATEWAY, + TALER_MHD_MAKE_JSON_PACK ( + TALER_JSON_pack_ec ( + TALER_EC_MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS), + TMH_pack_exchange_reply (&dr->hr))); return; } if (NULL == dr->hr.reply) @@ -2048,13 +2054,58 @@ phase_success_response (struct PayContext *pc) &pc->hc->instance->merchant_priv, &sig); /* Build the response */ - pos_confirmation = (NULL == pc->check_contract.pos_key) - ? NULL - : TALER_build_pos_confirmation ( - pc->check_contract.pos_key, - pc->check_contract.pos_alg, - &pc->validate_tokens.brutto, - pc->check_contract.contract_terms->pc->timestamp); + switch (pc->check_contract.pos_alg) + { + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + if (NULL == pc->check_contract.pos_key) + { + GNUNET_break (0); + pay_end (pc, + TALER_MHD_reply_with_error ( + pc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "order lacks the key needed to sign the POS confirmation")); + return; + } + pos_confirmation + = TALER_build_pos_confirmation_sig (pc->check_contract.pos_key, + pc->check_contract.pos_alg, + &pc->check_contract.pos_challenge); + if (NULL == pos_confirmation) + { + GNUNET_break (0); + pay_end (pc, + TALER_MHD_reply_with_error ( + pc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "failed to sign the POS confirmation")); + return; + } + break; + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + pos_confirmation = (NULL == pc->check_contract.pos_key) + ? NULL + : TALER_build_pos_confirmation ( + pc->check_contract.pos_key, + pc->check_contract.pos_alg, + &pc->validate_tokens.brutto, + pc->check_contract.contract_terms->pc->timestamp); + break; + default: + GNUNET_break (0); + pay_end (pc, + TALER_MHD_reply_with_error ( + pc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "unknown POS confirmation algorithm")); + return; + } pay_end (pc, TALER_MHD_REPLY_JSON_PACK ( pc->connection, @@ -2302,7 +2353,7 @@ add_donation_receipt_outputs ( } /* copy donau signatures into output array */ - for (unsigned int j=0; j<pc->parse_wallet_data.num_bkps; j++) + for (unsigned int j = 0; j<pc->parse_wallet_data.num_bkps; j++) { struct SignedOutputToken *sot; @@ -4697,7 +4748,8 @@ phase_check_contract (struct PayContext *pc) &paid, NULL, &pc->check_contract.pos_key, - &pc->check_contract.pos_alg); + &pc->check_contract.pos_alg, + &pc->check_contract.pos_challenge); if (0 > qs) { /* single, read-only SQL statements should never cause diff --git a/src/backend/taler-merchant-httpd_post-private-orders.c b/src/backend/taler-merchant-httpd_post-private-orders.c @@ -96,7 +96,7 @@ * refuses a forced download. */ #define MAX_KEYS_WAIT \ - GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MILLISECONDS, 2500) + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MILLISECONDS, 2500) /** * Generate the base URL for the given merchant instance. @@ -273,6 +273,14 @@ struct OrderContext enum TALER_MerchantConfirmationAlgorithm pos_algorithm; /** + * Challenge from the offline verifier to sign once the order is + * paid. Only meaningful when @e pos_algorithm is a + * challenge-signature algorithm: the parser rejects a request + * that pairs a challenge with any other algorithm. + */ + struct TALER_PosChallengeP pos_challenge; + + /** * Hash of the POST request data, used to detect * idempotent requests. */ @@ -993,7 +1001,15 @@ execute_transaction (struct OrderContext *oc) &oc->parse_request.claim_token, oc->serialize_order.contract, /* called 'contract terms' at database. */ oc->parse_request.pos_key, - oc->parse_request.pos_algorithm); + oc->parse_request.pos_algorithm, + /* the parser only accepts a challenge + together with these algorithms */ + ( (TALER_MCA_ECDSA_CHALLENGE + == oc->parse_request.pos_algorithm) || + (TALER_MCA_EDDSA_CHALLENGE + == oc->parse_request.pos_algorithm) ) + ? &oc->parse_request.pos_challenge + : NULL); if (qs <= 0) { /* qs == 0: probably instance does not exist (anymore) */ @@ -4008,6 +4024,7 @@ phase_parse_request (struct OrderContext *oc) const json_t *uuid = NULL; const char *otp_id = NULL; bool create_token = true; /* default */ + bool no_challenge = true; struct GNUNET_JSON_Specification spec[] = { GNUNET_JSON_spec_json ("order", &oc->parse_request.order), @@ -4039,6 +4056,10 @@ phase_parse_request (struct OrderContext *oc) TALER_JSON_spec_slug ("otp_id", &otp_id), NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_fixed_auto ("challenge", + &oc->parse_request.pos_challenge), + &no_challenge), GNUNET_JSON_spec_end () }; enum GNUNET_GenericReturnValue ret; @@ -4100,6 +4121,42 @@ phase_parse_request (struct OrderContext *oc) oc->parse_request.pos_key = td.otp_key; oc->parse_request.pos_algorithm = td.otp_algorithm; GNUNET_free (td.otp_description); + GNUNET_free (td.otp_device_pub); + } + /* pos_algorithm is TALER_MCA_NONE when the order references no OTP + device, so this also rejects a challenge sent on its own */ + { + switch (oc->parse_request.pos_algorithm) + { + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + /* the confirmation is a signature over the challenge, so + without one there would be nothing to sign at payment time */ + if (no_challenge) + { + GNUNET_break_op (0); + reply_with_error (oc, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MISSING, + "challenge"); + return; + } + break; + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + /* these are time-based; a challenge would never be looked at */ + if (! no_challenge) + { + GNUNET_break_op (0); + reply_with_error (oc, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "challenge"); + return; + } + break; + } } if (create_token) { diff --git a/src/backend/taler-merchant-httpd_post-private-otp-devices.c b/src/backend/taler-merchant-httpd_post-private-otp-devices.c @@ -48,12 +48,28 @@ static bool otp_devices_equal (const struct TALER_MERCHANTDB_OtpDeviceDetails *t1, const struct TALER_MERCHANTDB_OtpDeviceDetails *t2) { - return ( (0 == strcmp (t1->otp_description, - t2->otp_description)) && - (0 == strcmp (t1->otp_key, - t2->otp_key) ) && - (t1->otp_ctr == t2->otp_ctr) && - (t1->otp_algorithm == t2->otp_algorithm) ); + if ( (0 != strcmp (t1->otp_description, + t2->otp_description)) || + (t1->otp_ctr != t2->otp_ctr) || + (t1->otp_algorithm != t2->otp_algorithm) ) + return false; + switch (t1->otp_algorithm) + { + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + /* the key was generated here and never shown to the client, so + there is nothing of theirs left to compare */ + return true; + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + return ( (NULL == t1->otp_key) == (NULL == t2->otp_key) ) && + ( (NULL == t1->otp_key) || + (0 == strcmp (t1->otp_key, + t2->otp_key)) ); + } + GNUNET_break (0); + return false; } @@ -66,6 +82,8 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh, struct TALER_MERCHANTDB_OtpDeviceDetails tp = { 0 }; const char *device_id; enum GNUNET_DB_QueryStatus qs; + enum MHD_Result ret; + bool generated_keys = false; struct GNUNET_JSON_Specification spec[] = { TALER_JSON_spec_slug ("otp_device_id", &device_id), @@ -77,8 +95,10 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh, GNUNET_JSON_spec_uint64 ("otp_ctr", &tp.otp_ctr), NULL), - TALER_JSON_spec_otp_key ("otp_key", - (const char **) &tp.otp_key), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_otp_key ("otp_key", + (const char **) &tp.otp_key), + NULL), GNUNET_JSON_spec_end () }; @@ -98,6 +118,36 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh, } } + switch (tp.otp_algorithm) + { + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + if (NULL != tp.otp_key) + { + GNUNET_break_op (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "otp_key"); + goto cleanup; + } + break; + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + /* the merchant shares these with the OTP application itself */ + if (NULL == tp.otp_key) + { + GNUNET_break_op (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MISSING, + "otp_key"); + goto cleanup; + } + break; + } + /* finally, interact with DB until no serialization error */ for (unsigned int i = 0; i<MAX_RETRIES; i++) { @@ -109,11 +159,11 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh, "/post otp-devices")) { GNUNET_break (0); - GNUNET_JSON_parse_free (spec); - return TALER_MHD_reply_with_error (connection, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_START_FAILED, - NULL); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_START_FAILED, + NULL); + goto cleanup; } qs = TALER_MERCHANTDB_get_otp_device (TMH_db, mi->settings.id, @@ -125,11 +175,11 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh, /* Clean up and fail hard */ GNUNET_break (0); TALER_MERCHANTDB_rollback (TMH_db); - GNUNET_JSON_parse_free (spec); - return TALER_MHD_reply_with_error (connection, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_FETCH_FAILED, - NULL); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + NULL); + goto cleanup; case GNUNET_DB_STATUS_SOFT_ERROR: /* restart transaction */ goto retry; @@ -146,20 +196,61 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh, GNUNET_free (etp.otp_description); GNUNET_free (etp.otp_key); TALER_MERCHANTDB_rollback (TMH_db); - GNUNET_JSON_parse_free (spec); - return eq - ? TALER_MHD_reply_static (connection, - MHD_HTTP_NO_CONTENT, - NULL, - NULL, - 0) - : TALER_MHD_reply_with_error (connection, - MHD_HTTP_CONFLICT, - TALER_EC_MERCHANT_PRIVATE_POST_OTP_DEVICES_CONFLICT_OTP_DEVICE_EXISTS, - device_id); + if (! eq) + ret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_PRIVATE_POST_OTP_DEVICES_CONFLICT_OTP_DEVICE_EXISTS, + device_id); + else if (NULL != etp.otp_device_pub) + /* repeating the request returns the key generated the first + time; the merchant is shown it once per request, never a + new one */ + ret = TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_string ("otp_device_pub", + etp.otp_device_pub)); + else + ret = TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); + GNUNET_free (etp.otp_device_pub); + goto cleanup; } } /* end switch (qs) */ + switch (tp.otp_algorithm) + { + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + /* an insert that hit a serialization failure brings us back here + with the pair of the previous round still in hand */ + if (NULL != tp.otp_device_pub) + break; + if (GNUNET_OK != + TALER_otp_device_key_create (tp.otp_algorithm, + &tp.otp_key, + &tp.otp_device_pub)) + { + GNUNET_break (0); + TALER_MERCHANTDB_rollback (TMH_db); + ret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + "failed to generate the OTP device key pair"); + goto cleanup; + } + generated_keys = true; + break; + default: + /* the TOTP algorithms use the key the merchant supplied */ + break; + } + qs = TALER_MERCHANTDB_insert_otp_device (TMH_db, mi->settings.id, device_id, @@ -174,12 +265,12 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh, /* A concurrent request created an OTP device with the same ID between our lookup above and this INSERT. */ TALER_MERCHANTDB_rollback (TMH_db); - GNUNET_JSON_parse_free (spec); - return TALER_MHD_reply_with_error ( + ret = TALER_MHD_reply_with_error ( connection, MHD_HTTP_CONFLICT, TALER_EC_MERCHANT_PRIVATE_POST_OTP_DEVICES_CONFLICT_OTP_DEVICE_EXISTS, device_id); + goto cleanup; } if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs) { @@ -191,23 +282,42 @@ retry: GNUNET_assert (GNUNET_DB_STATUS_SOFT_ERROR == qs); TALER_MERCHANTDB_rollback (TMH_db); } /* for RETRIES loop */ - GNUNET_JSON_parse_free (spec); if (qs < 0) { GNUNET_break (0); - return TALER_MHD_reply_with_error ( + ret = TALER_MHD_reply_with_error ( connection, MHD_HTTP_INTERNAL_SERVER_ERROR, (GNUNET_DB_STATUS_SOFT_ERROR == qs) ? TALER_EC_GENERIC_DB_SOFT_FAILURE : TALER_EC_GENERIC_DB_COMMIT_FAILED, NULL); + goto cleanup; + } + if (NULL != tp.otp_device_pub) + { + ret = TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_string ("otp_device_pub", + tp.otp_device_pub)); } - return TALER_MHD_reply_static (connection, - MHD_HTTP_NO_CONTENT, - NULL, - NULL, - 0); + else + ret = TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); +cleanup: + /* TOTP keys are borrowed from the request JSON. Only a successfully + generated challenge key pair belongs to this handler. */ + if (generated_keys) + { + GNUNET_free (tp.otp_key); + GNUNET_free (tp.otp_device_pub); + } + GNUNET_JSON_parse_free (spec); + return ret; } diff --git a/src/backend/taler-merchant-httpd_post-templates-TEMPLATE_ID.c b/src/backend/taler-merchant-httpd_post-templates-TEMPLATE_ID.c @@ -223,6 +223,17 @@ struct UseContext bool no_amount; /** + * Challenge from the offline verifier, to be signed once the + * order is paid. + */ + struct TALER_PosChallengeP challenge; + + /** + * True if @e challenge was not provided. + */ + bool no_challenge; + + /** * True if @e tip was not provided. */ bool no_tip; @@ -705,6 +716,10 @@ handle_phase_parse_request ( TALER_JSON_spec_amount_any ("amount", &uc->parse_request.amount), &uc->parse_request.no_amount), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_fixed_auto ("challenge", + &uc->parse_request.challenge), + &uc->parse_request.no_challenge), GNUNET_JSON_spec_end () }; enum GNUNET_GenericReturnValue res; @@ -2209,6 +2224,17 @@ handle_phase_create_order (struct UseContext *uc) GNUNET_TIME_relative_to_timestamp ( uc->template_contract.pay_duration)))); } + if (! uc->parse_request.no_challenge) + { + /* POST /private/orders checks this against the algorithm of the + OTP device the template refers to */ + GNUNET_assert (0 == + json_object_set_new ( + uc->ihc.request_body, + "challenge", + GNUNET_JSON_from_data_auto ( + &uc->parse_request.challenge))); + } uc->phase++; } diff --git a/src/backenddb/get_contract_terms_pos.c b/src/backenddb/get_contract_terms_pos.c @@ -34,7 +34,8 @@ TALER_MERCHANTDB_get_contract_terms_pos ( bool *paid, struct TALER_ClaimTokenP *claim_token, char **pos_key, - enum TALER_MerchantConfirmationAlgorithm *pos_algorithm) + enum TALER_MerchantConfirmationAlgorithm *pos_algorithm, + struct TALER_PosChallengeP *pos_challenge) { enum GNUNET_DB_QueryStatus qs; struct TALER_ClaimTokenP ct; @@ -43,6 +44,7 @@ TALER_MERCHANTDB_get_contract_terms_pos ( GNUNET_PQ_query_param_end }; uint32_t pos32 = TALER_MCA_NONE; + bool no_challenge; struct GNUNET_PQ_ResultSpec rs[] = { /* contract_terms must be first! */ TALER_PQ_result_spec_json ("contract_terms", @@ -61,6 +63,10 @@ TALER_MERCHANTDB_get_contract_terms_pos ( GNUNET_PQ_result_spec_uint32 ("pos_algorithm", &pos32), NULL), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_auto_from_type ("pos_challenge", + pos_challenge), + &no_challenge), GNUNET_PQ_result_spec_end }; @@ -75,6 +81,7 @@ TALER_MERCHANTDB_get_contract_terms_pos ( ",paid" ",pos_key" ",pos_algorithm" + ",pos_challenge" " FROM merchant_contract_terms" " WHERE order_id=$1"); qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, @@ -83,6 +90,29 @@ TALER_MERCHANTDB_get_contract_terms_pos ( (NULL != contract_terms) ? rs : &rs[1]); + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) + return qs; + switch (pos32) + { + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + break; + default: + GNUNET_break (0); + GNUNET_PQ_cleanup_result ((NULL != contract_terms) ? rs : &rs[1]); + return GNUNET_DB_STATUS_HARD_ERROR; + } + if (no_challenge && + ( (TALER_MCA_ECDSA_CHALLENGE == pos32) || + (TALER_MCA_EDDSA_CHALLENGE == pos32) )) + { + GNUNET_break (0); + GNUNET_PQ_cleanup_result ((NULL != contract_terms) ? rs : &rs[1]); + return GNUNET_DB_STATUS_HARD_ERROR; + } *pos_algorithm = (enum TALER_MerchantConfirmationAlgorithm) pos32; if (NULL != claim_token) *claim_token = ct; diff --git a/src/backenddb/get_otp_device.c b/src/backenddb/get_otp_device.c @@ -37,12 +37,20 @@ TALER_MERCHANTDB_get_otp_device ( }; uint32_t pos32 = TALER_MCA_NONE; struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("otp_serial", + &td->otp_serial), GNUNET_PQ_result_spec_string ("otp_description", &td->otp_description), GNUNET_PQ_result_spec_uint64 ("otp_ctr", &td->otp_ctr), - GNUNET_PQ_result_spec_string ("otp_key", - &td->otp_key), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_string ("otp_key", + &td->otp_key), + NULL), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_string ("otp_device_pub", + &td->otp_device_pub), + NULL), GNUNET_PQ_result_spec_uint32 ("otp_algorithm", &pos32), GNUNET_PQ_result_spec_end @@ -55,8 +63,10 @@ TALER_MERCHANTDB_get_otp_device ( TMH_PQ_prepare_anon (pg, "SELECT" " otp_description" + ",otp_serial" ",otp_ctr" ",otp_key" + ",otp_device_pub" ",otp_algorithm" " FROM merchant_otp_devices" " WHERE otp_id=$1"); @@ -64,6 +74,21 @@ TALER_MERCHANTDB_get_otp_device ( "", params, rs); + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs) + return qs; + switch (pos32) + { + case TALER_MCA_NONE: + case TALER_MCA_WITHOUT_PRICE: + case TALER_MCA_WITH_PRICE: + case TALER_MCA_ECDSA_CHALLENGE: + case TALER_MCA_EDDSA_CHALLENGE: + break; + default: + GNUNET_break (0); + GNUNET_PQ_cleanup_result (rs); + return GNUNET_DB_STATUS_HARD_ERROR; + } td->otp_algorithm = (enum TALER_MerchantConfirmationAlgorithm) pos32; return qs; } diff --git a/src/backenddb/insert_contract_terms.c b/src/backenddb/insert_contract_terms.c @@ -103,7 +103,8 @@ TALER_MERCHANTDB_insert_contract_terms ( ",fulfillment_url" ",claim_token" ",pos_key" - ",pos_algorithm)" + ",pos_algorithm" + ",pos_challenge)" "SELECT" " mo.order_serial" ",mo.order_id" @@ -116,6 +117,7 @@ TALER_MERCHANTDB_insert_contract_terms ( ",mo.claim_token" ",mo.pos_key" ",mo.pos_algorithm" + ",mo.pos_challenge" " FROM merchant_orders mo" " WHERE order_id=$1" " RETURNING order_serial"); diff --git a/src/backenddb/insert_order.c b/src/backenddb/insert_order.c @@ -36,7 +36,8 @@ TALER_MERCHANTDB_insert_order ( const struct TALER_ClaimTokenP *claim_token, const json_t *contract_terms, const char *pos_key, - enum TALER_MerchantConfirmationAlgorithm pos_algorithm) + enum TALER_MerchantConfirmationAlgorithm pos_algorithm, + const struct TALER_PosChallengeP *pos_challenge) { struct GNUNET_TIME_Timestamp now; uint32_t pos32 = (uint32_t) pos_algorithm; @@ -54,6 +55,9 @@ TALER_MERCHANTDB_insert_order ( ? GNUNET_PQ_query_param_null () : GNUNET_PQ_query_param_string (pos_key), GNUNET_PQ_query_param_uint32 (&pos32), + (NULL == pos_challenge) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_auto_from_type (pos_challenge), (NULL == session_id) ? GNUNET_PQ_query_param_string ("") : GNUNET_PQ_query_param_string (session_id), @@ -81,10 +85,12 @@ TALER_MERCHANTDB_insert_order ( ",contract_terms" ",pos_key" ",pos_algorithm" + ",pos_challenge" ",session_id" ",fulfillment_url)" " VALUES" - " ($1, $2, $3, $4, $5, $6::TEXT::JSONB, $7, $8, $9, $10)"); + " ($1, $2, $3, $4, $5, $6::TEXT::JSONB," + " $7, $8, $9, $10, $11)"); return GNUNET_PQ_eval_prepared_non_select (pg->conn, "", params); diff --git a/src/backenddb/insert_otp_device.c b/src/backenddb/insert_otp_device.c @@ -44,7 +44,12 @@ TALER_MERCHANTDB_insert_otp_device ( struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_string (otp_id), GNUNET_PQ_query_param_string (td->otp_description), - GNUNET_PQ_query_param_string (td->otp_key), + (NULL == td->otp_key) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_string (td->otp_key), + (NULL == td->otp_device_pub) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_string (td->otp_device_pub), GNUNET_PQ_query_param_uint32 (&pos32), GNUNET_PQ_query_param_uint64 (&td->otp_ctr), GNUNET_PQ_query_param_end @@ -58,10 +63,11 @@ TALER_MERCHANTDB_insert_otp_device ( "(otp_id" ",otp_description" ",otp_key" + ",otp_device_pub" ",otp_algorithm" ",otp_ctr" ")" - " VALUES ($1, $2, $3, $4, $5)" + " VALUES ($1, $2, $3, $4, $5, $6)" " ON CONFLICT (otp_id) DO NOTHING"); return GNUNET_PQ_eval_prepared_non_select (pg->conn, "", diff --git a/src/backenddb/sql-schema/drop.sql b/src/backenddb/sql-schema/drop.sql @@ -19,13 +19,21 @@ BEGIN; -- This script DROPs all of the tables we create. -WITH xpatches AS ( - SELECT patch_name - FROM _v.patches - WHERE starts_with(patch_name,'merchant-') -) - SELECT _v.unregister_patch(xpatches.patch_name) - FROM xpatches; +-- On a database that was never initialized there is no versioning +-- schema yet, and referencing _v.patches would abort the whole script +-- before the DROPs below ever run. +DO $$ +BEGIN + IF EXISTS (SELECT 1 + FROM pg_namespace + WHERE nspname = '_v') + THEN + PERFORM _v.unregister_patch (patch_name) + FROM _v.patches + WHERE starts_with (patch_name,'merchant-'); + END IF; +END +$$; -- Drop all per-instance schemas created by the per-instance trigger. diff --git a/src/backenddb/sql-schema/merchant-0048.sql b/src/backenddb/sql-schema/merchant-0048.sql @@ -0,0 +1,66 @@ +-- +-- 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 merchant-0048.sql +-- @brief Add challenge-signature POS confirmations (DD 97) +-- @author Bohdan Potuzhnyi + +BEGIN; + +SELECT _v.register_patch('merchant-0048', NULL, NULL); + +SET search_path TO merchant; + +CREATE PROCEDURE merchant.merchant_0048_init(s TEXT) + LANGUAGE plpgsql + AS $OUTER$ +BEGIN + EXECUTE format('SET LOCAL search_path TO %I', s); + + -- For the challenge-signature algorithms the backend generates the + -- key pair itself: otp_key then holds the private key and only + -- otp_device_pub is ever handed back to the merchant. + ALTER TABLE merchant_otp_devices + ADD COLUMN otp_device_pub TEXT DEFAULT NULL; + COMMENT ON COLUMN merchant_otp_devices.otp_device_pub + IS 'Crockford base32-encoded public key of a challenge-signature device, NULL for the TOTP algorithms. The matching private key is kept in otp_key and never leaves the backend.'; + + -- The challenge is chosen by the offline verifier, travels through + -- the wallet when the template is instantiated, and is signed once + -- the order is paid. + ALTER TABLE merchant_orders + ADD COLUMN pos_challenge BYTEA CHECK(LENGTH(pos_challenge)=32) DEFAULT NULL; + COMMENT ON COLUMN merchant_orders.pos_challenge + IS 'Challenge to sign when pos_algorithm is a challenge-signature algorithm, NULL otherwise'; + + ALTER TABLE merchant_contract_terms + ADD COLUMN pos_challenge BYTEA CHECK(LENGTH(pos_challenge)=32) DEFAULT NULL; + COMMENT ON COLUMN merchant_contract_terms.pos_challenge + IS 'Challenge to sign when pos_algorithm is a challenge-signature algorithm, NULL otherwise'; + + SET LOCAL search_path TO merchant; +END +$OUTER$; + +INSERT INTO merchant.instance_fixups + (migration_name + ,version) + VALUES + ('merchant_0048_init' + ,48); +-- Apply new fix-up to existing instances +CALL merchant.fixup_instance_schema (48::INT8); + +COMMIT; diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build @@ -143,6 +143,7 @@ generated_sql = [ ['merchant-0045.sql'], ['merchant-0046.sql'], ['merchant-0047.sql'], + ['merchant-0048.sql'], ] migration_sql = [] diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -60,6 +60,10 @@ #include "merchant-database/insert_order.h" #include "merchant-database/insert_order_lock.h" #include "merchant-database/insert_otp_device.h" +#include "merchant-database/get_otp_device.h" +#include "merchant-database/update_otp_device.h" +#include "merchant-database/delete_otp_device.h" +#include "merchant-database/get_contract_terms_pos.h" #include "merchant-database/insert_pending_webhook.h" #include "merchant-database/insert_product.h" #include "merchant-database/insert_refund_proof.h" @@ -1686,6 +1690,122 @@ post_test_products (struct TestProducts_Closure *cls) /** + * Ensure a device update cannot use validation of a replaced device or + * an obsolete algorithm. The writes between lookup and update model + * the relevant interleaving of concurrent requests deterministically. + */ +static int +test_otp_device_conditional_update (const struct InstanceData *instance) +{ + const char *id = "conditional-update"; + struct TALER_MERCHANTDB_OtpDeviceDetails td = { + .otp_description = (char *) "original", + .otp_key = (char *) "original-private", + .otp_device_pub = (char *) "original-public", + .otp_algorithm = TALER_MCA_ECDSA_CHALLENGE + }; + struct TALER_MERCHANTDB_OtpDeviceDetails original; + struct TALER_MERCHANTDB_OtpDeviceDetails current; + uint64_t original_serial; + uint64_t replacement_serial; + + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_insert_otp_device (pg, + instance->instance.id, + id, + &td)); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_otp_device (pg, + instance->instance.id, + id, + &original)); + original_serial = original.otp_serial; + GNUNET_free (original.otp_description); + GNUNET_free (original.otp_key); + GNUNET_free (original.otp_device_pub); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_delete_otp_device (pg, + instance->instance.id, + id)); + td.otp_description = (char *) "replacement"; + td.otp_key = (char *) "replacement-private"; + td.otp_device_pub = (char *) "replacement-public"; + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_insert_otp_device (pg, + instance->instance.id, + id, + &td)); + /* Same ID and algorithm, but a different database identity. */ + td.otp_description = (char *) "stale update"; + td.otp_key = NULL; + td.otp_device_pub = NULL; + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == + TALER_MERCHANTDB_update_otp_device ( + pg, instance->instance.id, id, + original_serial, TALER_MCA_ECDSA_CHALLENGE, &td)); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_otp_device (pg, + instance->instance.id, + id, + &current)); + replacement_serial = current.otp_serial; + GNUNET_assert (original_serial != replacement_serial); + GNUNET_assert (0 == strcmp ("replacement", current.otp_description)); + GNUNET_assert (0 == strcmp ("replacement-private", current.otp_key)); + GNUNET_assert (0 == strcmp ("replacement-public", current.otp_device_pub)); + GNUNET_free (current.otp_description); + GNUNET_free (current.otp_key); + GNUNET_free (current.otp_device_pub); + /* Model another writer changing the algorithm of this same row. */ + td.otp_algorithm = TALER_MCA_EDDSA_CHALLENGE; + td.otp_description = (char *) "concurrent update"; + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_update_otp_device ( + pg, instance->instance.id, id, + replacement_serial, TALER_MCA_ECDSA_CHALLENGE, &td)); + td.otp_algorithm = TALER_MCA_ECDSA_CHALLENGE; + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == + TALER_MERCHANTDB_update_otp_device ( + pg, instance->instance.id, id, + replacement_serial, TALER_MCA_ECDSA_CHALLENGE, &td)); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_otp_device (pg, + instance->instance.id, + id, + &current)); + GNUNET_assert (TALER_MCA_EDDSA_CHALLENGE == current.otp_algorithm); + GNUNET_assert (0 == strcmp ("concurrent update", current.otp_description)); + GNUNET_free (current.otp_description); + GNUNET_free (current.otp_key); + GNUNET_free (current.otp_device_pub); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_delete_otp_device (pg, + instance->instance.id, + id)); + /* Unknown stored algorithms must fail before exposing an enum or keys. */ + for (unsigned int invalid = 0; invalid < 2; invalid++) + { + td.otp_algorithm = invalid ? -1 : TALER_MCA_EDDSA_CHALLENGE + 1; + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_insert_otp_device ( + pg, instance->instance.id, id, &td)); + GNUNET_assert (GNUNET_DB_STATUS_HARD_ERROR == + TALER_MERCHANTDB_get_otp_device ( + pg, instance->instance.id, id, &current)); + GNUNET_assert (NULL == current.otp_description); + GNUNET_assert (NULL == current.otp_key); + GNUNET_assert (NULL == current.otp_device_pub); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_delete_otp_device ( + pg, instance->instance.id, id)); + } + return 0; +} + + +/** * Tests that inserting a category, an OTP device or a webhook that * already exists is reported as #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS * and, crucially, does *not* abort the enclosing transaction. @@ -1953,6 +2073,7 @@ run_test_products (struct TestProducts_Closure *cls) TEST_RET_ON_FAIL (test_update_category_conflict (&cls->instance)); /* Test that duplicate inserts do not poison the transaction */ TEST_RET_ON_FAIL (test_insert_duplicate_conflicts (&cls->instance)); + TEST_RET_ON_FAIL (test_otp_device_conditional_update (&cls->instance)); /* Test inserting a product */ TEST_RET_ON_FAIL (test_insert_product (&cls->instance, &cls->products[0], @@ -3162,7 +3283,8 @@ test_insert_order (const struct InstanceData *instance, &order->claim_token, order->contract, NULL, - 0), + 0, + NULL), "Insert order failed\n"); return 0; } @@ -3575,6 +3697,81 @@ test_insert_contract_terms (const struct InstanceData *instance, /** + * Reject missing challenges for challenge algorithms, including callers + * that do not request contract JSON. An explicitly stored zero challenge + * must remain distinguishable from SQL NULL. + */ +static int +test_contract_terms_challenge (const struct InstanceData *instance, + const struct OrderData *order) +{ + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + GNUNET_assert (GNUNET_OK == + TALER_MERCHANTDB_start (pg, "test stored POS challenge")); + for (uint32_t alg = TALER_MCA_NONE; + alg <= TALER_MCA_EDDSA_CHALLENGE + 1; + alg++) + { + for (unsigned int missing = 0; missing < 2; missing++) + { + struct TALER_PosChallengeP challenge = {0}; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (order->id), + GNUNET_PQ_query_param_uint32 (&alg), + missing ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_auto_from_type (&challenge), + GNUNET_PQ_query_param_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon ( + pg->conn, + "UPDATE merchant_contract_terms" + " SET pos_algorithm=$2, pos_challenge=$3, pos_key='test-key'" + " WHERE order_id=$1")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_non_select (pg->conn, "", params)); + for (unsigned int with_json = 0; with_json < 2; with_json++) + { + json_t *contract = NULL; + char *key = NULL; + uint64_t serial; + bool paid; + enum TALER_MerchantConfirmationAlgorithm parsed_alg; + enum GNUNET_DB_QueryStatus qs; + struct TALER_PosChallengeP parsed_challenge; + + memset (&parsed_challenge, 42, sizeof (parsed_challenge)); + qs = TALER_MERCHANTDB_get_contract_terms_pos ( + pg, instance->instance.id, order->id, + with_json ? &contract : NULL, + &serial, &paid, NULL, &key, &parsed_alg, &parsed_challenge); + if ( (alg > TALER_MCA_EDDSA_CHALLENGE) || + (missing && (alg >= TALER_MCA_ECDSA_CHALLENGE)) ) + { + GNUNET_assert (GNUNET_DB_STATUS_HARD_ERROR == qs); + GNUNET_assert (NULL == contract); + GNUNET_assert (NULL == key); + } + else + { + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs); + GNUNET_assert (alg == parsed_alg); + if (! missing) + GNUNET_assert (0 == GNUNET_memcmp (&challenge, &parsed_challenge)); + json_decref (contract); + GNUNET_free (key); + } + } + } + } + TALER_MERCHANTDB_rollback (pg); + return 0; +} + + +/** * Test updating contract terms for an order. * * @param instance the instance. @@ -4016,6 +4213,8 @@ run_test_orders (struct TestOrders_Closure *cls) TEST_RET_ON_FAIL (test_insert_contract_terms (&cls->instance, &cls->orders[0], GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_contract_terms_challenge (&cls->instance, + &cls->orders[0])); /* Test double insert fails */ TEST_RET_ON_FAIL (test_insert_contract_terms (&cls->instance, &cls->orders[0], @@ -8344,6 +8543,7 @@ kyc_event_cb (void *cls, (*fired)++; } + /** * Check the notification encoding used by the SQL refresh request. */ diff --git a/src/backenddb/update_otp_device.c b/src/backenddb/update_otp_device.c @@ -29,9 +29,12 @@ TALER_MERCHANTDB_update_otp_device ( struct TALER_MERCHANTDB_PostgresContext *pg, const char *instance_id, const char *otp_id, + uint64_t expected_serial, + enum TALER_MerchantConfirmationAlgorithm expected_algorithm, const struct TALER_MERCHANTDB_OtpDeviceDetails *td) { uint32_t pos32 = (uint32_t) td->otp_algorithm; + uint32_t expected_pos32 = (uint32_t) expected_algorithm; struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_string (otp_id), GNUNET_PQ_query_param_string (td->otp_description), @@ -40,6 +43,11 @@ TALER_MERCHANTDB_update_otp_device ( (NULL == td->otp_key) ? GNUNET_PQ_query_param_null () : GNUNET_PQ_query_param_string (td->otp_key), + (NULL == td->otp_device_pub) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_string (td->otp_device_pub), + GNUNET_PQ_query_param_uint64 (&expected_serial), + GNUNET_PQ_query_param_uint32 (&expected_pos32), GNUNET_PQ_query_param_end }; @@ -52,7 +60,10 @@ TALER_MERCHANTDB_update_otp_device ( ",otp_algorithm=$3" ",otp_ctr=$4" ",otp_key=COALESCE($5,otp_key)" - " WHERE otp_id=$1"); + ",otp_device_pub=COALESCE($6,otp_device_pub)" + " WHERE otp_id=$1" + " AND otp_serial=$7" + " AND otp_algorithm=$8"); return GNUNET_PQ_eval_prepared_non_select (pg->conn, "", params); diff --git a/src/include/merchant-database/get_contract_terms_pos.h b/src/include/merchant-database/get_contract_terms_pos.h @@ -39,7 +39,10 @@ struct TALER_MERCHANTDB_PostgresContext; * @param[out] claim_token set to the claim token, NULL to only check for existence * @param[out] pos_key encoded key for payment verification * @param[out] pos_algorithm algorithm to compute the payment verification - * @return transaction status + * @param[out] pos_challenge set to the challenge to sign, left alone + * for the algorithms that do not use one + * @return transaction status, #GNUNET_DB_STATUS_HARD_ERROR if a challenge + * algorithm has no stored challenge */ enum GNUNET_DB_QueryStatus TALER_MERCHANTDB_get_contract_terms_pos ( @@ -51,6 +54,7 @@ TALER_MERCHANTDB_get_contract_terms_pos ( bool *paid, struct TALER_ClaimTokenP *claim_token, char **pos_key, - enum TALER_MerchantConfirmationAlgorithm *pos_algorithm); + enum TALER_MerchantConfirmationAlgorithm *pos_algorithm, + struct TALER_PosChallengeP *pos_challenge); #endif diff --git a/src/include/merchant-database/insert_order.h b/src/include/merchant-database/insert_order.h @@ -41,6 +41,8 @@ struct TALER_MERCHANTDB_PostgresContext; * @param contract_terms proposal data to store * @param pos_key encoded key for payment verification * @param pos_algorithm algorithm to compute the payment verification + * @param pos_challenge challenge to sign when @a pos_algorithm is a + * challenge-signature algorithm, NULL otherwise * @return transaction status */ enum GNUNET_DB_QueryStatus @@ -53,6 +55,7 @@ TALER_MERCHANTDB_insert_order (struct TALER_MERCHANTDB_PostgresContext *pg, const struct TALER_ClaimTokenP *claim_token, const json_t *contract_terms, const char *pos_key, - enum TALER_MerchantConfirmationAlgorithm pos_algorithm); + enum TALER_MerchantConfirmationAlgorithm pos_algorithm, + const struct TALER_PosChallengeP *pos_challenge); #endif diff --git a/src/include/merchant-database/update_otp_device.h b/src/include/merchant-database/update_otp_device.h @@ -33,15 +33,18 @@ struct TALER_MERCHANTDB_PostgresContext; * @param pg database context * @param instance_id instance to update OTP device for * @param otp_id OTP device to update - * @param td update to the OTP device details on success, can be NULL - * (in that case we only want to check if the template exists) - * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the template - * does not yet exist. + * @param expected_serial database identity of the device that was validated + * @param expected_algorithm algorithm of the device that was validated + * @param td new OTP device details + * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the + * device was deleted, replaced, or its algorithm changed since validation */ enum GNUNET_DB_QueryStatus TALER_MERCHANTDB_update_otp_device (struct TALER_MERCHANTDB_PostgresContext *pg, const char *instance_id, const char *otp_id, + uint64_t expected_serial, + enum TALER_MerchantConfirmationAlgorithm expected_algorithm, const struct TALER_MERCHANTDB_OtpDeviceDetails *td); diff --git a/src/include/merchantdb_lib.h b/src/include/merchantdb_lib.h @@ -299,6 +299,11 @@ struct TALER_MERCHANTDB_OtpDeviceDetails { /** + * Database identity of the device, set when reading its details. + */ + uint64_t otp_serial; + + /** * Description of the device. */ char *otp_description; @@ -309,11 +314,21 @@ struct TALER_MERCHANTDB_OtpDeviceDetails uint64_t otp_ctr; /** - * Base64-encoded key. + * RFC 3548 Base32-encoded key for TOTP. For the challenge-signature + * algorithms this is the Crockford Base32-encoded private key the + * backend generated, and it never leaves the backend. NULL if the + * device has no key. */ char *otp_key; /** + * Crockford base32-encoded public key of a challenge-signature + * device, NULL for the TOTP algorithms. This is the only part of + * the key pair that is handed back to the merchant. + */ + char *otp_device_pub; + + /** * Algorithm used to compute purchase confirmations. */ enum TALER_MerchantConfirmationAlgorithm otp_algorithm; diff --git a/src/include/taler/merchant/get-private-otp-devices-DEVICE_ID.h b/src/include/taler/merchant/get-private-otp-devices-DEVICE_ID.h @@ -84,6 +84,14 @@ struct TALER_MERCHANT_GetPrivateOtpDeviceResponse */ enum TALER_MerchantConfirmationAlgorithm otp_alg; + /** + * Crockford base32-encoded public key of a challenge-signature + * device, NULL for the TOTP algorithms. + * Keep this after otp_alg to preserve the response prefix for + * applications compiled against older library versions. + */ + const char *otp_device_pub; + } ok; } details; diff --git a/src/include/taler/merchant/post-private-orders.h b/src/include/taler/merchant/post-private-orders.h @@ -104,7 +104,12 @@ enum TALER_MERCHANT_PostPrivateOrdersOption /** * Lock UUIDs for inventory locks. */ - TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_LOCK_UUIDS + TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_LOCK_UUIDS, + + /** + * Challenge for an ECDSA or EdDSA confirmation device. + */ + TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_CHALLENGE }; @@ -157,6 +162,12 @@ struct TALER_MERCHANT_PostPrivateOrdersOptionValue const char *otp_id; /** + * Value for #TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_CHALLENGE. + * Copied when setting options; NULL clears the challenge. + */ + const struct TALER_PosChallengeP *challenge; + + /** * Value if @e option is * #TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_INVENTORY_PRODUCTS. */ @@ -404,6 +415,21 @@ struct TALER_MERCHANT_PostPrivateOrdersResponse } /** + * Set the 32-byte challenge for a challenge-signature OTP device. + * Required with algorithms 3 and 4; forbidden with TOTP or no OTP device. + * + * @param c challenge to copy, NULL to clear it + * @return representation of the option + */ +#define TALER_MERCHANT_post_private_orders_option_challenge(c) \ + (const struct TALER_MERCHANT_PostPrivateOrdersOptionValue) \ + { \ + .option = TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_CHALLENGE, \ + .details.challenge = (c) \ + } + + +/** * Set inventory products. * * @param n number of products diff --git a/src/include/taler/merchant/post-private-otp-devices.h b/src/include/taler/merchant/post-private-otp-devices.h @@ -42,6 +42,29 @@ struct TALER_MERCHANT_PostPrivateOtpDevicesResponse */ struct TALER_MERCHANT_HttpResponse hr; + /** + * Details depending on the HTTP status code. + */ + union + { + + /** + * Details on #MHD_HTTP_OK, returned when the device uses a + * challenge-signature algorithm and the backend generated its key + * pair. + */ + struct + { + /** + * Crockford base32-encoded public key of the device. This is + * the only time the backend hands it out for a fresh device; + * the private key never leaves the backend. + */ + const char *otp_device_pub; + } ok; + + } details; + }; diff --git a/src/include/taler/merchant/post-templates-TEMPLATE_ID.h b/src/include/taler/merchant/post-templates-TEMPLATE_ID.h @@ -48,7 +48,14 @@ enum TALER_MERCHANT_PostTemplatesOption /** * Detailed contract customization (JSON). */ - TALER_MERCHANT_POST_TEMPLATES_OPTION_DETAILS + TALER_MERCHANT_POST_TEMPLATES_OPTION_DETAILS, + + /** + * Challenge from an offline verifier, to be signed once the order + * is paid. Only for templates whose OTP device uses a + * challenge-signature algorithm. + */ + TALER_MERCHANT_POST_TEMPLATES_OPTION_CHALLENGE }; @@ -88,6 +95,12 @@ struct TALER_MERCHANT_PostTemplatesOptionValue */ const json_t *details; + /** + * Value if @e option is + * #TALER_MERCHANT_POST_TEMPLATES_OPTION_CHALLENGE. + */ + const struct TALER_PosChallengeP *challenge; + } details; }; @@ -181,6 +194,20 @@ struct TALER_MERCHANT_PostTemplatesResponse .details.amount = (a) \ } + +/** + * Challenge from an offline verifier to be signed once the order is + * paid. + * + * @param c challenge to send + */ +#define TALER_MERCHANT_post_templates_option_challenge(c) \ + (const struct TALER_MERCHANT_PostTemplatesOptionValue) \ + { \ + .option = TALER_MERCHANT_POST_TEMPLATES_OPTION_CHALLENGE, \ + .details.challenge = (c) \ + } + /** * Set detailed contract customization. * diff --git a/src/include/taler/taler_merchant_testing_lib.h b/src/include/taler/taler_merchant_testing_lib.h @@ -827,6 +827,23 @@ TALER_TESTING_cmd_merchant_delete_product (const char *label, /* ******************* /orders **************** */ /** + * Associate a direct order command with an OTP device and optional challenge. + * + * @param cmd command created by TALER_TESTING_cmd_merchant_post_orders() + * @param otp_id device ID, or NULL for no device + * @param otp_ref device command providing verification traits, or NULL + * @param with_challenge generate and submit a challenge + * @return modified command + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_orders_with_otp ( + struct TALER_TESTING_Command cmd, + const char *otp_id, + const char *otp_ref, + bool with_challenge); + + +/** * Make the "proposal" command. * * @param label command label @@ -1841,7 +1858,9 @@ TALER_TESTING_cmd_merchant_kyc_get ( * POST /otps request. * @param otp_id the ID of the otp device to modify * @param otp_description description of the otp device - * @param otp_key base32-encoded key to verify the payment + * @param otp_key base32-encoded key to verify the payment, NULL for + * the challenge-signature algorithms where the backend + * generates the key pair itself * @param otp_alg is an option that show the amount of the order. it is linked with the @a otp_key * @param otp_ctr counter to use (if in counter mode) * @param http_status expected HTTP response code. @@ -2700,6 +2719,8 @@ TALER_TESTING_cmd_exec_donaukeyupdate (const char *label, op (otp_id, const char) \ op (otp_key, const char) \ op (otp_alg, const enum TALER_MerchantConfirmationAlgorithm) \ + op (otp_device_pub, const char) \ + op (pos_challenge, const struct TALER_PosChallengeP) \ op (template_id, const char) \ op (template_contract, const json_t) \ op (event_type, const char) \ diff --git a/src/lib/merchant_api_get-config.c b/src/lib/merchant_api_get-config.c @@ -34,12 +34,12 @@ * Which version of the Taler protocol is implemented * by this library? Used to determine compatibility. */ -#define MERCHANT_PROTOCOL_CURRENT 42 +#define MERCHANT_PROTOCOL_CURRENT 43 /** * How many configs are we backwards-compatible with? */ -#define MERCHANT_PROTOCOL_AGE 18 +#define MERCHANT_PROTOCOL_AGE 19 /** * How many exchanges do we allow at most per merchant? diff --git a/src/lib/merchant_api_get-private-otp-devices-DEVICE_ID.c b/src/lib/merchant_api_get-private-otp-devices-DEVICE_ID.c @@ -138,6 +138,10 @@ handle_get_otp_device_finished (void *cls, GNUNET_JSON_spec_string ("otp_code", &ogr.details.ok.otp_code), NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("otp_device_pub", + &ogr.details.ok.otp_device_pub), + NULL), GNUNET_JSON_spec_end () }; diff --git a/src/lib/merchant_api_patch-private-otp-devices-DEVICE_ID.c b/src/lib/merchant_api_patch-private-otp-devices-DEVICE_ID.c @@ -150,6 +150,7 @@ handle_patch_otp_device_finished (void *cls, odr.hr.ec = TALER_JSON_get_error_code (json); odr.hr.hint = TALER_JSON_get_error_hint (json); break; + case MHD_HTTP_CONFLICT: case MHD_HTTP_NOT_FOUND: odr.hr.ec = TALER_JSON_get_error_code (json); odr.hr.hint = TALER_JSON_get_error_hint (json); diff --git a/src/lib/merchant_api_post-private-orders.c b/src/lib/merchant_api_post-private-orders.c @@ -116,6 +116,13 @@ struct TALER_MERCHANT_PostPrivateOrdersHandle const char *otp_id; /** + * Challenge copied from the options, if present. + */ + struct TALER_PosChallengeP challenge; + + bool have_challenge; + + /** * Optional inventory products. */ const struct TALER_MERCHANT_PostPrivateOrdersInventoryProduct * @@ -407,6 +414,11 @@ TALER_MERCHANT_post_private_orders_set_options_ ( case TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_OTP_ID: ppoh->otp_id = options[i].details.otp_id; break; + case TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_CHALLENGE: + ppoh->have_challenge = (NULL != options[i].details.challenge); + if (ppoh->have_challenge) + ppoh->challenge = *options[i].details.challenge; + break; case TALER_MERCHANT_POST_PRIVATE_ORDERS_OPTION_INVENTORY_PRODUCTS: ppoh->num_inventory_products = options[i].details.inventory_products.num; @@ -454,6 +466,12 @@ TALER_MERCHANT_post_private_orders_start ( GNUNET_JSON_pack_allow_null ( GNUNET_JSON_pack_string ("otp_id", ppoh->otp_id))); + if (ppoh->have_challenge) + GNUNET_assert (0 == + json_object_set_new ( + req, + "challenge", + GNUNET_JSON_from_data_auto (&ppoh->challenge))); if (ppoh->refund_delay_set && (0 != ppoh->refund_delay.rel_value_us)) { diff --git a/src/lib/merchant_api_post-private-otp-devices.c b/src/lib/merchant_api_post-private-otp-devices.c @@ -137,6 +137,25 @@ handle_post_otp_devices_finished (void *cls, break; case MHD_HTTP_NO_CONTENT: break; + case MHD_HTTP_OK: + { + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("otp_device_pub", + &odr.details.ok.otp_device_pub), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (json, + spec, + NULL, NULL)) + { + GNUNET_break_op (0); + odr.hr.http_status = 0; + odr.hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; + } + break; + } case MHD_HTTP_BAD_REQUEST: odr.hr.ec = TALER_JSON_get_error_code (json); odr.hr.hint = TALER_JSON_get_error_hint (json); diff --git a/src/lib/merchant_api_post-templates-TEMPLATE_ID.c b/src/lib/merchant_api_post-templates-TEMPLATE_ID.c @@ -93,6 +93,11 @@ struct TALER_MERCHANT_PostTemplatesHandle * Optional detailed contract customization (JSON). */ const json_t *details; + + /** + * Challenge to sign once the order is paid, if any. + */ + const struct TALER_PosChallengeP *challenge; }; @@ -234,6 +239,9 @@ TALER_MERCHANT_post_templates_set_options_ ( case TALER_MERCHANT_POST_TEMPLATES_OPTION_DETAILS: pth->details = options[i].details.details; break; + case TALER_MERCHANT_POST_TEMPLATES_OPTION_CHALLENGE: + pth->challenge = options[i].details.challenge; + break; default: GNUNET_break (0); return GNUNET_SYSERR; @@ -269,8 +277,10 @@ TALER_MERCHANT_post_templates_start ( return TALER_EC_GENERIC_CONFIGURATION_INVALID; if (NULL != pth->details) { - /* If detailed contract customization is provided, send it directly. */ - req_obj = json_incref ((json_t *) pth->details); + /* The per-request challenge must not change caller-owned details. + Only top-level fields are modified, copy is good. */ + req_obj = json_copy ((json_t *) pth->details); + GNUNET_assert (NULL != req_obj); } else { @@ -285,6 +295,14 @@ TALER_MERCHANT_post_templates_start ( TALER_JSON_pack_amount ("amount", pth->amount))); } + if (NULL != pth->challenge) + { + GNUNET_assert (0 == + json_object_set_new ( + req_obj, + "challenge", + GNUNET_JSON_from_data_auto (pth->challenge))); + } eh = TALER_MERCHANT_curl_easy_get_ (pth->url); if ( (NULL == eh) || (GNUNET_OK != diff --git a/src/testing/test_merchant_api.c b/src/testing/test_merchant_api.c @@ -2159,6 +2159,426 @@ run (void *cls, "EUR:4.99", "EUR:4.99", NULL), + + /* DD 97: the full challenge-signature flow, once per algorithm. + The backend generates the device key pair and returns only the + public half; the template instantiation carries a challenge + chosen on behalf of an offline verifier; and after payment the + pay command checks the returned pos_confirmation against that + public key, exactly as the offline device would. */ + cmd_transfer_to_exchange ("create-reserve-dd97", + "EUR:40.08"), + cmd_exec_wirewatch ("wirewatch-dd97"), + TALER_TESTING_cmd_check_bank_admin_transfer ("check_bank_transfer-dd97", + "EUR:40.08", + payer_payto, + exchange_payto, + "create-reserve-dd97"), + TALER_TESTING_cmd_withdraw_amount ("withdraw-coin-dd97a", + "create-reserve-dd97", + "EUR:5", + 0, + MHD_HTTP_OK), + TALER_TESTING_cmd_withdraw_amount ("withdraw-coin-dd97b", + "create-reserve-dd97", + "EUR:5", + 0, + MHD_HTTP_OK), + TALER_TESTING_cmd_withdraw_amount ("withdraw-coin-dd97c", + "create-reserve-dd97", + "EUR:5", + 0, + MHD_HTTP_OK), + TALER_TESTING_cmd_withdraw_amount ("withdraw-coin-dd97d", + "create-reserve-dd97", + "EUR:5", + 0, + MHD_HTTP_OK), + + TALER_TESTING_cmd_withdraw_amount ( + "withdraw-coin-dd97e", "create-reserve-dd97", "EUR:5", 0, MHD_HTTP_OK), + TALER_TESTING_cmd_withdraw_amount ( + "withdraw-coin-dd97f", "create-reserve-dd97", "EUR:5", 0, MHD_HTTP_OK), + TALER_TESTING_cmd_withdraw_amount ( + "withdraw-coin-dd97g", "create-reserve-dd97", "EUR:5", 0, MHD_HTTP_OK), + TALER_TESTING_cmd_withdraw_amount ( + "withdraw-coin-dd97h", "create-reserve-dd97", "EUR:5", 0, MHD_HTTP_OK), + + /* ECDSA (NIST P-256) */ + TALER_TESTING_cmd_merchant_post_otp_devices ( + "post-otp-device-ecdsa-supplied-key", + merchant_url, + "otp-dev-ecdsa-supplied-key", + "client-supplied key is forbidden", + "JBSWY3DPEHPK3PXP", + TALER_MCA_ECDSA_CHALLENGE, + 0, + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_otp_devices ( + "post-otp-device-ecdsa", + merchant_url, + "otp-dev-ecdsa", + "an ECDSA challenge device", + NULL, /* the backend generates the key pair */ + TALER_MCA_ECDSA_CHALLENGE, + 0, + MHD_HTTP_OK), + /* Metadata updates remain valid, but changing the signing algorithm + must fail without breaking the payment confirmation below. */ + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-ecdsa-same-algorithm", + merchant_url, + "otp-dev-ecdsa", + "an ECDSA challenge device", + NULL, + TALER_MCA_ECDSA_CHALLENGE, + 0, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-ecdsa-to-eddsa", + merchant_url, + "otp-dev-ecdsa", + "an ECDSA challenge device", + NULL, + TALER_MCA_EDDSA_CHALLENGE, + 0, + MHD_HTTP_CONFLICT), + TALER_TESTING_cmd_merchant_get_otp_device ( + "get-otp-device-ecdsa-after-rejected-switch", + merchant_url, + "otp-dev-ecdsa", + MHD_HTTP_OK, + "post-otp-device-ecdsa"), + TALER_TESTING_cmd_merchant_post_templates2 ( + "post-templates-ecdsa", + merchant_url, + "template-ecdsa", + "template bound to an ECDSA challenge device", + "otp-dev-ecdsa", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_uint64 ("minimum_age", 0), + GNUNET_JSON_pack_time_rel ("pay_duration", + GNUNET_TIME_UNIT_MINUTES)), + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "using-templates-ecdsa-missing-challenge", + "post-templates-ecdsa", + NULL, + merchant_url, + "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "negative challenge test"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98")), + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "using-templates-ecdsa-malformed-challenge", + "post-templates-ecdsa", + NULL, + merchant_url, + "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "negative challenge test"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98"), + GNUNET_JSON_pack_string ("challenge", "!")), + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "using-templates-ecdsa-short-challenge", + "post-templates-ecdsa", + NULL, + merchant_url, + "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "negative challenge test"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98"), + GNUNET_JSON_pack_string ("challenge", "00")), + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_using_templates ( + "using-templates-ecdsa", + "post-templates-ecdsa", + "post-otp-device-ecdsa", + merchant_url, + "1", + "summary-ecdsa", + "EUR:9.98", + GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, + MHD_HTTP_OK), + TALER_TESTING_cmd_merchant_pay_order ("pay-ecdsa-challenge", + merchant_url, + MHD_HTTP_OK, + "using-templates-ecdsa", + "withdraw-coin-dd97a;withdraw-coin-dd97b", + "EUR:4.99", + "EUR:4.99", + NULL), + + /* Repeating payment returns a confirmation for the same challenge. */ + TALER_TESTING_cmd_merchant_pay_order ( + "pay-ecdsa-challenge-retry", + merchant_url, + MHD_HTTP_OK, + "using-templates-ecdsa", + "withdraw-coin-dd97a;withdraw-coin-dd97b", + "EUR:4.99", + "EUR:4.99", + NULL), + + /* EdDSA (Ed25519) */ + TALER_TESTING_cmd_merchant_post_otp_devices ( + "post-otp-device-eddsa-supplied-key", + merchant_url, + "otp-dev-eddsa-supplied-key", + "client-supplied key is forbidden", + "JBSWY3DPEHPK3PXP", + TALER_MCA_EDDSA_CHALLENGE, + 0, + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_otp_devices ( + "post-otp-device-eddsa", + merchant_url, + "otp-dev-eddsa", + "an EdDSA challenge device", + NULL, /* the backend generates the key pair */ + TALER_MCA_EDDSA_CHALLENGE, + 0, + MHD_HTTP_OK), + /* Metadata updates remain valid, but changing the signing algorithm + must fail without breaking the payment confirmation below. */ + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-eddsa-same-algorithm", + merchant_url, + "otp-dev-eddsa", + "an EdDSA challenge device", + NULL, + TALER_MCA_EDDSA_CHALLENGE, + 0, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-eddsa-to-ecdsa", + merchant_url, + "otp-dev-eddsa", + "an EdDSA challenge device", + NULL, + TALER_MCA_ECDSA_CHALLENGE, + 0, + MHD_HTTP_CONFLICT), + TALER_TESTING_cmd_merchant_get_otp_device ( + "get-otp-device-eddsa-after-rejected-switch", + merchant_url, + "otp-dev-eddsa", + MHD_HTTP_OK, + "post-otp-device-eddsa"), + TALER_TESTING_cmd_merchant_post_templates2 ( + "post-templates-eddsa", + merchant_url, + "template-eddsa", + "template bound to an EdDSA challenge device", + "otp-dev-eddsa", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_uint64 ("minimum_age", 0), + GNUNET_JSON_pack_time_rel ("pay_duration", + GNUNET_TIME_UNIT_MINUTES)), + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "using-templates-eddsa-missing-challenge", + "post-templates-eddsa", + NULL, + merchant_url, + "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "negative challenge test"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98")), + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "using-templates-eddsa-malformed-challenge", + "post-templates-eddsa", + NULL, + merchant_url, + "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "negative challenge test"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98"), + GNUNET_JSON_pack_string ("challenge", "!")), + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "using-templates-eddsa-short-challenge", + "post-templates-eddsa", + NULL, + merchant_url, + "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "negative challenge test"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98"), + GNUNET_JSON_pack_string ("challenge", "00")), + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_using_templates ( + "using-templates-eddsa", + "post-templates-eddsa", + "post-otp-device-eddsa", + merchant_url, + "1", + "summary-eddsa", + "EUR:9.98", + GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, + MHD_HTTP_OK), + TALER_TESTING_cmd_merchant_pay_order ("pay-eddsa-challenge", + merchant_url, + MHD_HTTP_OK, + "using-templates-eddsa", + "withdraw-coin-dd97c;withdraw-coin-dd97d", + "EUR:4.99", + "EUR:4.99", + NULL), + + /* Repeating payment returns a confirmation for the same challenge. */ + TALER_TESTING_cmd_merchant_pay_order ( + "pay-eddsa-challenge-retry", + merchant_url, + MHD_HTTP_OK, + "using-templates-eddsa", + "withdraw-coin-dd97c;withdraw-coin-dd97d", + "EUR:4.99", + "EUR:4.99", + NULL), + + TALER_TESTING_cmd_merchant_post_orders_with_otp ( + TALER_TESTING_cmd_merchant_post_orders ( + "direct-ecdsa-challenge", cred.cfg, merchant_url, MHD_HTTP_OK, + "direct-ecdsa-challenge", GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, "EUR:9.98"), + "otp-dev-ecdsa", "post-otp-device-ecdsa", true), + TALER_TESTING_cmd_merchant_post_orders_with_otp ( + TALER_TESTING_cmd_merchant_post_orders ( + "direct-eddsa-challenge", cred.cfg, merchant_url, MHD_HTTP_OK, + "direct-eddsa-challenge", GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, "EUR:9.98"), + "otp-dev-eddsa", "post-otp-device-eddsa", true), + TALER_TESTING_cmd_merchant_post_orders_with_otp ( + TALER_TESTING_cmd_merchant_post_orders ( + "direct-missing-challenge", cred.cfg, merchant_url, MHD_HTTP_BAD_REQUEST, + "direct-missing-challenge", GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, "EUR:9.98"), + "otp-dev-ecdsa", NULL, false), + TALER_TESTING_cmd_merchant_post_orders_with_otp ( + TALER_TESTING_cmd_merchant_post_orders ( + "direct-unexpected-challenge", cred.cfg, merchant_url, MHD_HTTP_BAD_REQUEST, + "direct-unexpected-challenge", GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, "EUR:9.98"), + NULL, NULL, true), + + TALER_TESTING_cmd_merchant_pay_order ( + "pay-direct-ecdsa-challenge", merchant_url, MHD_HTTP_OK, + "direct-ecdsa-challenge", "withdraw-coin-dd97e;withdraw-coin-dd97f", + "EUR:4.99", "EUR:4.99", NULL), + TALER_TESTING_cmd_merchant_pay_order ( + "pay-direct-eddsa-challenge", merchant_url, MHD_HTTP_OK, + "direct-eddsa-challenge", "withdraw-coin-dd97g;withdraw-coin-dd97h", + "EUR:4.99", "EUR:4.99", NULL), + TALER_TESTING_cmd_merchant_post_orders_with_otp ( + TALER_TESTING_cmd_merchant_post_orders ( + "direct-totp-unexpected-challenge", cred.cfg, merchant_url, MHD_HTTP_BAD_REQUEST, + "direct-totp-unexpected-challenge", GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, "EUR:9.98"), + "otp-dev-2", NULL, true), + TALER_TESTING_cmd_merchant_post_templates2 ( + "post-templates-no-challenge-device", merchant_url, + "template-no-challenge-device", "no device", NULL, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_uint64 ("minimum_age", 0), + GNUNET_JSON_pack_time_rel ("pay_duration", GNUNET_TIME_UNIT_MINUTES)), + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "template-no-device-unexpected-challenge", "post-templates-no-challenge-device", NULL, + merchant_url, "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "unexpected challenge"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98"), + GNUNET_JSON_pack_string ( + "challenge", "0000000000000000000000000000000000000000000000000000")), + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_using_templates2 ( + "template-totp-unexpected-challenge", "post-templates-with-pos-key", NULL, + merchant_url, "1", + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("summary", "unexpected challenge"), + GNUNET_JSON_pack_string ("amount", "EUR:9.98"), + GNUNET_JSON_pack_string ( + "challenge", "0000000000000000000000000000000000000000000000000000")), + MHD_HTTP_BAD_REQUEST), + + /* an algorithm cannot move between the TOTP and the + challenge-signature families */ + TALER_TESTING_cmd_merchant_post_otp_devices ( + "post-otp-device-mig-totp", + merchant_url, + "otp-dev-mig-totp", + "a TOTP device we try to convert", + "FEE4P2J", + TALER_MCA_WITH_PRICE, + 0, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-totp-to-ecdsa", + merchant_url, + "otp-dev-mig-totp", + "a TOTP device we try to convert", + NULL, /* supplying a key here is refused separately */ + TALER_MCA_ECDSA_CHALLENGE, + 0, + MHD_HTTP_CONFLICT), + /* the refused PATCH left the device as it was */ + TALER_TESTING_cmd_merchant_get_otp_device ( + "get-otp-device-mig-totp", + merchant_url, + "otp-dev-mig-totp", + MHD_HTTP_OK, + "post-otp-device-mig-totp"), + TALER_TESTING_cmd_merchant_post_otp_devices ( + "post-otp-device-mig-eddsa", + merchant_url, + "otp-dev-mig-eddsa", + "an EdDSA device we try to convert", + NULL, /* the backend generates the key pair */ + TALER_MCA_EDDSA_CHALLENGE, + 0, + MHD_HTTP_OK), + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-eddsa-to-totp", + merchant_url, + "otp-dev-mig-eddsa", + "an EdDSA device we try to convert", + NULL, + TALER_MCA_WITH_PRICE, + 0, + MHD_HTTP_CONFLICT), + /* the private key was not exposed through the TOTP construction */ + TALER_TESTING_cmd_merchant_get_otp_device ( + "get-otp-device-mig-eddsa", + merchant_url, + "otp-dev-mig-eddsa", + MHD_HTTP_OK, + "post-otp-device-mig-eddsa"), + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-mig-nx", + merchant_url, + "otp-dev-mig-nonexistent", + "a device that was never created", + NULL, + TALER_MCA_ECDSA_CHALLENGE, + 0, + MHD_HTTP_NOT_FOUND), + /* staying inside the TOTP family is still allowed */ + TALER_TESTING_cmd_merchant_patch_otp_device ( + "patch-otp-device-totp-to-totp", + merchant_url, + "otp-dev-mig-totp", + "a TOTP device we rekeyed", + "FEE4P2K", + TALER_MCA_WITHOUT_PRICE, + 0, + MHD_HTTP_NO_CONTENT), + cmd_transfer_to_exchange ("create-reserve-20y", "EUR:10.02"), cmd_exec_wirewatch ("wirewatch-20y"), diff --git a/src/testing/testing_api_cmd_patch_otp_device.c b/src/testing/testing_api_cmd_patch_otp_device.c @@ -120,6 +120,8 @@ patch_otp_device_cb (struct PatchOtpDeviceState *pis, case MHD_HTTP_NOT_FOUND: break; case MHD_HTTP_CONFLICT: + if (TALER_EC_MERCHANT_PRIVATE_PATCH_OTP_DEVICES_CONFLICT != hr->ec) + TALER_TESTING_FAIL (pis->is); break; default: GNUNET_log (GNUNET_ERROR_TYPE_WARNING, @@ -245,7 +247,9 @@ TALER_TESTING_cmd_merchant_patch_otp_device ( pis->otp_device_id = otp_device_id; pis->http_status = http_status; pis->otp_device_description = otp_device_description; - pis->otp_key = GNUNET_strdup (otp_key); + pis->otp_key = (NULL == otp_key) + ? NULL + : GNUNET_strdup (otp_key); pis->otp_alg = otp_alg; pis->otp_ctr = otp_ctr; { diff --git a/src/testing/testing_api_cmd_pay_order.c b/src/testing/testing_api_cmd_pay_order.c @@ -488,6 +488,16 @@ struct PayState enum TALER_MerchantConfirmationAlgorithm pos_alg; /** + * Public key of the OTP device, for a challenge-signature device. + */ + const char *pos_device_pub; + + /** + * Challenge the order carries, for a challenge-signature device. + */ + const struct TALER_PosChallengeP *pos_challenge; + + /** * Index of the choice to be used in the payment. -1 for orders without choices. */ int choice_index; @@ -858,6 +868,60 @@ pay_cb (struct PayState *ps, return; } } + if (NULL != ps->pos_device_pub) + { + /* what the offline verifier does: check the signature over the + challenge it chose, using only the device public key */ + if (NULL == pr->details.ok.pos_confirmation) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (ps->is); + return; + } + if (NULL == ps->pos_challenge) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (ps->is); + return; + } + if (GNUNET_OK != + TALER_check_pos_confirmation_sig (ps->pos_device_pub, + ps->pos_alg, + ps->pos_challenge, + pr->details.ok.pos_confirmation)) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (ps->is); + return; + } + } + if (NULL != ps->pos_device_pub) + { + struct TALER_PosChallengeP altered = *ps->pos_challenge; + char *wrong_key; + char *wrong_pub; + enum GNUNET_GenericReturnValue check; + + /* A signature for a previous challenge cannot authorize a new one. */ + ((unsigned char *) &altered)[0] ^= 1; + if (GNUNET_OK == + TALER_check_pos_confirmation_sig (ps->pos_device_pub, + ps->pos_alg, + &altered, + pr->details.ok.pos_confirmation)) + TALER_TESTING_FAIL (ps->is); + GNUNET_assert (GNUNET_OK == + TALER_otp_device_key_create (ps->pos_alg, + &wrong_key, + &wrong_pub)); + check = TALER_check_pos_confirmation_sig ( + wrong_pub, ps->pos_alg, ps->pos_challenge, + pr->details.ok.pos_confirmation); + GNUNET_free (wrong_key); + GNUNET_free (wrong_pub); + if (GNUNET_OK == check) + TALER_TESTING_FAIL (ps->is); + } if (NULL != ps->pos_key) { char *pc; @@ -956,6 +1020,14 @@ pay_run (void *cls, &alg_ptr)) && (NULL != alg_ptr) ) ps->pos_alg = *alg_ptr; + if (GNUNET_OK != + TALER_TESTING_get_trait_otp_device_pub (proposal_cmd, + &ps->pos_device_pub)) + ps->pos_device_pub = NULL; + if (GNUNET_OK != + TALER_TESTING_get_trait_pos_challenge (proposal_cmd, + &ps->pos_challenge)) + ps->pos_challenge = NULL; { /* Get information that needs to be put verbatim in the * deposit permission */ diff --git a/src/testing/testing_api_cmd_post_orders.c b/src/testing/testing_api_cmd_post_orders.c @@ -154,6 +154,16 @@ struct OrdersState * response is the same as in this command. */ const char *duplicate_of; + + /** + * Optional device association and verification material for direct orders. + */ + const char *otp_id; + const char *otp_ref; + const enum TALER_MerchantConfirmationAlgorithm *otp_alg; + const char *otp_device_pub; + struct TALER_PosChallengeP challenge; + bool with_challenge; }; @@ -182,6 +192,10 @@ orders_traits (void *cls, TALER_TESTING_make_trait_merchant_pub (&ps->merchant_pub), TALER_TESTING_make_trait_claim_nonce (&ps->nonce), TALER_TESTING_make_trait_claim_token (&ps->claim_token), + TALER_TESTING_make_trait_otp_alg (ps->otp_alg), + TALER_TESTING_make_trait_otp_device_pub (ps->otp_device_pub), + TALER_TESTING_make_trait_pos_challenge ( + ps->with_challenge ? &ps->challenge : NULL), TALER_TESTING_trait_end () }; @@ -330,6 +344,7 @@ order_cb (struct OrdersState *ps, } } break; + case MHD_HTTP_BAD_REQUEST: case MHD_HTTP_NOT_FOUND: TALER_TESTING_interpreter_next (ps->is); return; @@ -424,6 +439,32 @@ orders_run (void *cls, ps->merchant_url, ps->order_terms); GNUNET_assert (NULL != ps->po); + if (NULL != ps->otp_ref) + { + const struct TALER_TESTING_Command *ref; + + ref = TALER_TESTING_interpreter_lookup_command (is, ps->otp_ref); + if ( (GNUNET_OK != TALER_TESTING_get_trait_otp_alg (ref, &ps->otp_alg)) || + (GNUNET_OK != TALER_TESTING_get_trait_otp_device_pub ( + ref, &ps->otp_device_pub)) ) + TALER_TESTING_FAIL (is); + } + if (NULL != ps->otp_id) + TALER_MERCHANT_post_private_orders_set_options ( + ps->po, + TALER_MERCHANT_post_private_orders_option_otp_id (ps->otp_id)); + if (ps->with_challenge) + { + struct TALER_PosChallengeP temporary; + + GNUNET_CRYPTO_random_block (&ps->challenge, sizeof (ps->challenge)); + temporary = ps->challenge; + TALER_MERCHANT_post_private_orders_set_options ( + ps->po, + TALER_MERCHANT_post_private_orders_option_challenge (&temporary)); + /* The option promises to copy the challenge. */ + memset (&temporary, 0, sizeof (temporary)); + } { enum TALER_ErrorCode ec; @@ -1250,3 +1291,20 @@ TALER_TESTING_cmd_merchant_post_orders_donau ( return cmd; } } + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_orders_with_otp ( + struct TALER_TESTING_Command cmd, + const char *otp_id, + const char *otp_ref, + bool with_challenge) +{ + struct OrdersState *ps = cmd.cls; + + GNUNET_assert (&orders_run == cmd.run); + ps->otp_id = otp_id; + ps->otp_ref = otp_ref; + ps->with_challenge = with_challenge; + return cmd; +} diff --git a/src/testing/testing_api_cmd_post_otp_devices.c b/src/testing/testing_api_cmd_post_otp_devices.c @@ -73,6 +73,12 @@ struct PostOtpDevicesState enum TALER_MerchantConfirmationAlgorithm otp_alg; /** + * Public key the backend generated for a challenge-signature + * device, NULL for the TOTP algorithms. + */ + char *otp_device_pub; + + /** * Counter at the OTP device. */ uint64_t otp_ctr; @@ -108,10 +114,31 @@ post_otp_devices_cb (struct PostOtpDevicesState *tis, TALER_TESTING_interpreter_fail (tis->is); return; } + /* whatever the status, the key the backend keeps must not come back + out: for TOTP it is the shared secret, for the challenge-signature + algorithms it is the private key */ + if ( (NULL != odr->hr.reply) && + (NULL != json_object_get (odr->hr.reply, + "otp_key")) ) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "POST /otp-devices returned an otp_key to command %s\n", + TALER_TESTING_interpreter_get_current_label (tis->is)); + TALER_TESTING_interpreter_fail (tis->is); + return; + } switch (odr->hr.http_status) { case MHD_HTTP_NO_CONTENT: break; + case MHD_HTTP_OK: + /* the backend generated the key pair and hands us the public + half; this is the only time it is returned for a new device */ + tis->otp_device_pub + = GNUNET_strdup (odr->details.ok.otp_device_pub); + break; + case MHD_HTTP_BAD_REQUEST: case MHD_HTTP_UNAUTHORIZED: break; case MHD_HTTP_FORBIDDEN: @@ -197,6 +224,7 @@ post_otp_devices_traits (void *cls, ), TALER_TESTING_make_trait_otp_key (pts->otp_key), TALER_TESTING_make_trait_otp_alg (&pts->otp_alg), + TALER_TESTING_make_trait_otp_device_pub (pts->otp_device_pub), TALER_TESTING_make_trait_otp_id (pts->otp_device_id), TALER_TESTING_trait_end (), }; @@ -228,6 +256,7 @@ post_otp_devices_cleanup (void *cls, TALER_MERCHANT_post_private_otp_devices_cancel (tis->iph); } GNUNET_free (tis->otp_key); + GNUNET_free (tis->otp_device_pub); GNUNET_free (tis); } @@ -250,7 +279,11 @@ TALER_TESTING_cmd_merchant_post_otp_devices ( tis->otp_device_id = otp_device_id; tis->http_status = http_status; tis->otp_device_description = otp_device_description; - tis->otp_key = GNUNET_strdup (otp_key); + /* NULL for the challenge-signature algorithms: there the backend + generates the key pair and the client never supplies one */ + tis->otp_key = (NULL != otp_key) + ? GNUNET_strdup (otp_key) + : NULL; tis->otp_alg = otp_alg; tis->otp_ctr = otp_ctr; { diff --git a/src/testing/testing_api_cmd_post_using_templates.c b/src/testing/testing_api_cmd_post_using_templates.c @@ -183,6 +183,18 @@ struct PostUsingTemplatesState const enum TALER_MerchantConfirmationAlgorithm *otp_alg; /** + * Public key of the OTP device, for a challenge-signature device. + */ + const char *otp_device_pub; + + /** + * Challenge we made up on behalf of an offline verifier, sent with + * the request and expected back signed after payment. NULL unless + * the template's device signs a challenge. + */ + struct TALER_PosChallengeP *pos_challenge; + + /** * Expected HTTP response code. */ unsigned int http_status; @@ -315,11 +327,11 @@ post_using_templates_cb (struct PostUsingTemplatesState *tis, tis->pay_duration), GNUNET_TIME_UNIT_SECONDS); if ( (GNUNET_TIME_absolute_cmp (tis->pay_deadline.abs_time, - <, - earliest)) || + <, + earliest)) || (GNUNET_TIME_absolute_cmp (tis->pay_deadline.abs_time, - >, - latest)) ) + >, + latest)) ) { GNUNET_log (GNUNET_ERROR_TYPE_ERROR, "Template pay deadline does not match configured pay duration\n"); @@ -372,6 +384,7 @@ post_using_templates_cb (struct PostUsingTemplatesState *tis, } } break; + case MHD_HTTP_BAD_REQUEST: case MHD_HTTP_NOT_FOUND: TALER_TESTING_interpreter_next (tis->is); return; @@ -494,9 +507,24 @@ post_using_templates_run (void *cls, TALER_TESTING_get_trait_otp_alg (ref, &tis->otp_alg)) TALER_TESTING_FAIL (is); + /* only challenge-signature devices have one */ + if (GNUNET_OK != + TALER_TESTING_get_trait_otp_device_pub (ref, + &tis->otp_device_pub)) + tis->otp_device_pub = NULL; } GNUNET_CRYPTO_random_block (&tis->nonce, sizeof (struct GNUNET_CRYPTO_EddsaPublicKey)); + if ( (NULL != tis->otp_alg) && + ( (TALER_MCA_ECDSA_CHALLENGE == *tis->otp_alg) || + (TALER_MCA_EDDSA_CHALLENGE == *tis->otp_alg) ) ) + { + /* stand in for the offline verifier: pick an unpredictable + challenge that the backend must sign after payment */ + tis->pos_challenge = GNUNET_new (struct TALER_PosChallengeP); + GNUNET_CRYPTO_random_block (tis->pos_challenge, + sizeof (*tis->pos_challenge)); + } tis->iph = TALER_MERCHANT_post_templates_create ( TALER_TESTING_interpreter_get_context (is), tis->merchant_url, @@ -522,6 +550,11 @@ post_using_templates_run (void *cls, TALER_MERCHANT_post_templates_option_amount ( &tis->amount)); } + if (NULL != tis->pos_challenge) + TALER_MERCHANT_post_templates_set_options ( + tis->iph, + TALER_MERCHANT_post_templates_option_challenge ( + tis->pos_challenge)); { enum TALER_ErrorCode ec; @@ -562,6 +595,8 @@ post_using_templates_traits (void *cls, TALER_TESTING_make_trait_claim_token (&pts->claim_token), TALER_TESTING_make_trait_otp_key (pts->otp_key), TALER_TESTING_make_trait_otp_alg (pts->otp_alg), + TALER_TESTING_make_trait_otp_device_pub (pts->otp_device_pub), + TALER_TESTING_make_trait_pos_challenge (pts->pos_challenge), TALER_TESTING_trait_end (), }; @@ -596,6 +631,7 @@ post_using_templates_cleanup (void *cls, json_decref (tis->contract_terms); json_decref (tis->details); GNUNET_free (tis->order_id); + GNUNET_free (tis->pos_challenge); GNUNET_free (tis); }