merchant

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

commit 221a8fc339ce58d7e5718ba0a4f840812779ffcb
parent 78f6a9b0d23a97d75b48581e64c23016432588d1
Author: Florian Dold <dold@taler.net>
Date:   Sat, 29 Aug 2026 17:04:09 +0200

merchant MFA: bind challenges to their instance

Diffstat:
Msrc/backend/taler-merchant-httpd_mfa.c | 17+++++++++++++++++
Msrc/testing/meson.build | 4++++
Msrc/testing/test_merchant_mfa.sh | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 180 insertions(+), 0 deletions(-)

diff --git a/src/backend/taler-merchant-httpd_mfa.c b/src/backend/taler-merchant-httpd_mfa.c @@ -102,6 +102,7 @@ TMH_mfa_parse_challenge_id (struct TMH_HandlerContext *hc, * Check if the given authentication check was already completed. * * @param[in,out] hc handler context of the connection to authorize + * @param instance_name instance for which the challenge must have been issued * @param op operation for which we are requiring authorization * @param challenge_id ID of the challenge to check if it is done * @param[out] solved set to true if the challenge was solved, @@ -119,6 +120,7 @@ TMH_mfa_parse_challenge_id (struct TMH_HandlerContext *hc, static enum GNUNET_GenericReturnValue mfa_challenge_check ( struct TMH_HandlerContext *hc, + const char *instance_name, enum TALER_MERCHANT_MFA_CriticalOperation op, const char *challenge_id, bool *solved, @@ -189,6 +191,20 @@ mfa_challenge_check ( case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: break; } + if (0 != strcmp (instance_name, + instance_id)) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Challenge was issued for a different instance (%s!=%s)!\n", + instance_id, + instance_name); + GNUNET_free (instance_id); + GNUNET_free (*target_address); + *target_address = NULL; + *channel = TALER_MERCHANT_MFA_CHANNEL_NONE; + *retry_counter = UINT_MAX; + return GNUNET_OK; + } GNUNET_free (instance_id); if (xop != op) @@ -520,6 +536,7 @@ TMH_mfa_challenges_do ( uint32_t retry_counter; ret = mfa_challenge_check (hc, + instance_name, op, challenge_ids[i], &solved, diff --git a/src/testing/meson.build b/src/testing/meson.build @@ -7,6 +7,7 @@ check_SCRIPTS = [ 'test_merchant_instance_response', 'test_merchant_instance_purge', 'test_merchant_kyc', + 'test_merchant_mfa', 'test_merchant_order_creation', 'test_merchant_order_refund', 'test_merchant_order_autocleanup', @@ -368,6 +369,9 @@ EXTRA_DIST = [ 'test_merchant_api_twisted-rsa.conf', 'test_merchant_api_proxy_merchant.conf', 'test_merchant_api_proxy_exchange.conf', + 'test_merchant_mfa.conf', + 'test_email_helper.sh', + 'test_sms_helper.sh', 'test_merchant.priv', 'test_template.conf', ] diff --git a/src/testing/test_merchant_mfa.sh b/src/testing/test_merchant_mfa.sh @@ -27,6 +27,64 @@ setup \ -m LAST_RESPONSE=$(mktemp -p "${TMPDIR:-/tmp}" test_response.conf-XXXXXX) +solve_challenge() +{ + CHALLENGE_ID=$1 + CHANNEL=$2 + EXPECTED_ADDRESS=$3 + + rm -f "/tmp/test-merchant-$CHANNEL-tan.txt" \ + "/tmp/test-merchant-$CHANNEL-address.txt" + STATUS=$(curl -H "Content-Type: application/json" -X POST \ + "http://localhost:9966/challenge/$CHALLENGE_ID" \ + -d '{}' \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + if [ "$STATUS" != "200" ] + then + jq < "$LAST_RESPONSE" + exit_fail "Expected challenge transmission to return 200 OK. Got: $STATUS" + fi + + TAN=$(head -n1 "/tmp/test-merchant-$CHANNEL-tan.txt" | awk '{print $1}') + ADDRESS=$(cat "/tmp/test-merchant-$CHANNEL-address.txt") + if [ "$ADDRESS" != "$EXPECTED_ADDRESS" ] + then + exit_fail "Expected $CHANNEL address '$EXPECTED_ADDRESS'. Got: $ADDRESS" + fi + + STATUS=$(curl -H "Content-Type: application/json" -X POST \ + "http://localhost:9966/challenge/$CHALLENGE_ID/confirm" \ + -d '{"tan":"'"$TAN"'"}' \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + if [ "$STATUS" != "204" ] + then + jq < "$LAST_RESPONSE" + exit_fail "Expected challenge confirmation to return 204 No Content. Got: $STATUS" + fi +} + + +solve_response_challenges() +{ + RESPONSE=$1 + while IFS=$'\t' read -r CHALLENGE_ID CHANNEL + do + case "$CHANNEL" in + email) + solve_challenge "$CHALLENGE_ID" "$CHANNEL" "self@example.com" + ;; + sms) + solve_challenge "$CHALLENGE_ID" "$CHANNEL" "+4171234" + ;; + *) + exit_fail "Unexpected TAN channel: $CHANNEL" + ;; + esac + done < <(jq -r '.challenges[] | [.challenge_id, .tan_channel] | @tsv' "$RESPONSE") +} + echo -n "Configuring a merchant admin instance ..." STATUS=$(curl -H "Content-Type: application/json" -X POST \ @@ -419,6 +477,107 @@ echo " OK" +echo -n "Self-provision second instance for instance-binding test " +OTHER_INSTANCE_BODY='{"auth":{"method":"token","password":"amnesia"},"id":"other","name":"other","phone_number":"+4171234","email":"self@example.com","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us":50000000},"default_pay_delay":{"d_us":60000000}}' +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + http://localhost:9966/instances \ + -d "$OTHER_INSTANCE_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "202" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected 202 Accepted. Got: $STATUS" +fi +cp "$LAST_RESPONSE" "$LAST_RESPONSE.challenges" +OTHER_CREATE_CHALLENGE_IDS=$(jq -r \ + '[.challenges[].challenge_id] | join(",")' < "$LAST_RESPONSE") +solve_response_challenges "$LAST_RESPONSE.challenges" + +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H "Taler-Challenge-Ids: $OTHER_CREATE_CHALLENGE_IDS" \ + http://localhost:9966/instances \ + -d "$OTHER_INSTANCE_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "204" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected 204 No Content. Got: $STATUS" +fi +echo "OK" + + +echo -n "Solve token-creation challenge for first instance " +TOKEN_BODY='{"scope":"spa","duration":{"d_us":600000000},"refreshable":true}' +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H 'Authorization: Bearer secret-token:amnesia' \ + http://localhost:9966/instances/self/private/token \ + -d "$TOKEN_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "202" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected 202 Accepted. Got: $STATUS" +fi +INSTANCE_CHALLENGE_ID=$(jq -r '.challenges[0].challenge_id' < "$LAST_RESPONSE") +INSTANCE_CHALLENGE_CHANNEL=$(jq -r '.challenges[0].tan_channel' < "$LAST_RESPONSE") +case "$INSTANCE_CHALLENGE_CHANNEL" in +email) + INSTANCE_CHALLENGE_ADDRESS=self@example.com + ;; +sms) + INSTANCE_CHALLENGE_ADDRESS=+4171234 + ;; +*) + exit_fail "Unexpected TAN channel: $INSTANCE_CHALLENGE_CHANNEL" + ;; +esac +solve_challenge "$INSTANCE_CHALLENGE_ID" \ + "$INSTANCE_CHALLENGE_CHANNEL" \ + "$INSTANCE_CHALLENGE_ADDRESS" +echo "OK" + + +echo -n "Reject solved challenge at a different instance " +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H 'Authorization: Bearer secret-token:amnesia' \ + -H "Taler-Challenge-Ids: $INSTANCE_CHALLENGE_ID" \ + http://localhost:9966/instances/other/private/token \ + -d "$TOKEN_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "202" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected foreign challenge to be rejected with 202. Got: $STATUS" +fi +if jq -e --arg cid "$INSTANCE_CHALLENGE_ID" \ + '.challenges[] | select(.challenge_id == $cid)' "$LAST_RESPONSE" > /dev/null +then + exit_fail "Foreign challenge was returned as applicable to the other instance" +fi +echo "OK" + + +echo -n "Accept solved challenge at its original instance " +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H 'Authorization: Bearer secret-token:amnesia' \ + -H "Taler-Challenge-Ids: $INSTANCE_CHALLENGE_ID" \ + http://localhost:9966/instances/self/private/token \ + -d "$TOKEN_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected same-instance challenge to authorize request. Got: $STATUS" +fi +echo "OK" + + echo "TEST PASSED" exit 0