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:
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