commit 2dee4146e911b3e8b51e06384a695bbd94f3c45d
parent 77ec42ed423197a5b71f9489785045b724e69f93
Author: Florian Dold <dold@taler.net>
Date: Sun, 9 Aug 2026 17:09:52 +0200
Add Rusty mock MFA delivery
Diffstat:
9 files changed, 163 insertions(+), 0 deletions(-)
diff --git a/inventories/group_vars/all/defaults.yml b/inventories/group_vars/all/defaults.yml
@@ -18,6 +18,11 @@ ebics_keys_external: false
# deployments under any circumstance.
dangerously_enable_devtesting: false
+# Publish MFA challenge messages for development testing. This is intentionally
+# disabled by default and additionally guarded in playbooks/setup.yml so it can
+# only run on the Rusty staging host.
+devtesting_mock_mfa: false
+
# Configure EBICS.
# When only this setting is enabled,
# the EBICS config is only deployed,
diff --git a/inventories/host_vars/rusty/vars.yml b/inventories/host_vars/rusty/vars.yml
@@ -73,6 +73,11 @@ exchange_sanction_helper: taler-exchange-helper-sanctions-dummy
# deployments under any circumstance.
dangerously_enable_devtesting: true
+# MFA challenge messages are written to /var/www/mock-mfa and served at
+# https://exchange.{{ domain_name }}/mock-mfa/. Never enable this on a
+# production system.
+devtesting_mock_mfa: true
+
devtesting_ssh_keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINHSjJ/zPwQnqBrKp0qK+OdsZYfQ8DHY2dyJakNozBi7 fdold-work@sapota"
- "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAoXKfSbPriOPoFRR+lMAJszH0/7jaPZOxdg85/URlRbe0ljm8fxbVmup1EjGPuKKJkyYqJIqGQCRHPNYeBt05APXYEO+4d5WAuPY6QOiTFGxB5RueWHAjFM5LVKtBH9Ozln+ngjeXlID48ueuBY2LO24hRuZtRmHYKN1AwQNA2XKtjteKINx99ljm3uwVV9IDYAJkRWKllolLrSFfqK6CHDS/IqlMNp3qNNhNXEW+/Vm5kMUPzKvhPXH/OsFr2KyKaO/+zVXptwje9imtaYaD5iEuRbEfP+6OsCKKpIlp6kyfOUPLuxK+RQfDRY3pyHeCKGriv3DGUpCYqtFVZlmsww== stibane@feh.com"
diff --git a/playbooks/setup.yml b/playbooks/setup.yml
@@ -13,6 +13,15 @@
that: exchange_attribute_encryption_key is defined
quiet: true
+ - name: Restrict mock MFA to the Rusty staging host
+ ansible.builtin.assert:
+ that: inventory_hostname == 'rusty'
+ fail_msg: >-
+ devtesting_mock_mfa exposes MFA codes and may only be enabled on
+ the Rusty staging host.
+ quiet: true
+ when: devtesting_mock_mfa | bool
+
- name: "Check the KYCAID secrets"
when: deployment_kind == 'tops'
ansible.builtin.assert:
diff --git a/roles/challenger/files/usr/local/bin/mock-mfa-email b/roles/challenger/files/usr/local/bin/mock-mfa-email
@@ -0,0 +1,34 @@
+#!/bin/sh
+# Development-only email challenger delivery helper. Challenger provides the
+# email address as $1 and the MFA message on standard input.
+set -eu
+
+if [ "$#" -ne 1 ]; then
+ echo "usage: mock-mfa-email EMAIL_ADDRESS" >&2
+ exit 64
+fi
+
+# Keep real deliveries real. Only the reserved staging email addresses
+# documented for MyTOPS development testing are captured locally.
+case "$1" in
+ test-[0-9][0-9]@taler.net)
+ ;;
+ *)
+ exec /usr/bin/challenger-send-email "$@"
+ ;;
+esac
+
+message_dir=/var/www/mock-mfa
+# Keep email-address filenames readable while preventing a supplied address
+# from escaping the message directory.
+filename=$(LC_ALL=C printf '%s' "$1" | tr -c '[:alnum:]@+._-' '_')
+
+# Files must be readable by nginx through the challenger-mock-mfa group. Write
+# atomically so the web server never exposes a partially-written MFA message.
+umask 007
+tmpfile=$(mktemp "$message_dir/.${filename}.XXXXXX")
+trap 'rm -f "$tmpfile"' EXIT HUP INT TERM
+chmod 0640 "$tmpfile"
+cat >"$tmpfile"
+mv -f "$tmpfile" "$message_dir/$filename.txt"
+trap - EXIT HUP INT TERM
diff --git a/roles/challenger/files/usr/local/bin/mock-mfa-sms b/roles/challenger/files/usr/local/bin/mock-mfa-sms
@@ -0,0 +1,34 @@
+#!/bin/sh
+# Development-only SMS challenger delivery helper. Challenger provides the
+# phone number as $1 and the MFA message on standard input.
+set -eu
+
+if [ "$#" -ne 1 ]; then
+ echo "usage: mock-mfa-sms PHONE_NUMBER" >&2
+ exit 64
+fi
+
+# Keep real deliveries real. Only the reserved staging phone numbers
+# documented for MyTOPS development testing are captured locally.
+case "$1" in
+ +417000000[0-9][0-9])
+ ;;
+ *)
+ exec /usr/bin/challenger-send-sms "$@"
+ ;;
+esac
+
+message_dir=/var/www/mock-mfa
+# Keep phone-number filenames readable while preventing a supplied number from
+# escaping the message directory.
+filename=$(LC_ALL=C printf '%s' "$1" | tr -c '[:alnum:]@+._-' '_')
+
+# Files must be readable by nginx through the challenger-mock-mfa group. Write
+# atomically so the web server never exposes a partially-written MFA message.
+umask 007
+tmpfile=$(mktemp "$message_dir/.${filename}.XXXXXX")
+trap 'rm -f "$tmpfile"' EXIT HUP INT TERM
+chmod 0640 "$tmpfile"
+cat >"$tmpfile"
+mv -f "$tmpfile" "$message_dir/$filename.txt"
+trap - EXIT HUP INT TERM
diff --git a/roles/challenger/tasks/pre-exchange.yml b/roles/challenger/tasks/pre-exchange.yml
@@ -79,6 +79,66 @@
system: true
state: present
+- name: Ensure mock MFA group exists
+ ansible.builtin.group:
+ name: challenger-mock-mfa
+ state: present
+ when: devtesting_mock_mfa | bool
+
+- name: Grant challenger services and nginx access to mock MFA messages
+ ansible.builtin.user:
+ name: "{{ item }}"
+ groups: challenger-mock-mfa
+ append: true
+ loop:
+ - challenger-sms
+ - challenger-email
+ - challenger-postal
+ - www-data
+ notify: Restart nginx
+ when: devtesting_mock_mfa | bool
+
+- name: Ensure mock MFA message directory exists
+ ansible.builtin.file:
+ path: /var/www/mock-mfa
+ state: directory
+ owner: root
+ group: challenger-mock-mfa
+ mode: "2770"
+ when: devtesting_mock_mfa | bool
+
+- name: Remove mock MFA messages when mock MFA is disabled
+ ansible.builtin.file:
+ path: /var/www/mock-mfa
+ state: absent
+ when: not (devtesting_mock_mfa | bool)
+
+- name: Install mock MFA delivery helpers
+ ansible.builtin.copy:
+ src: "usr/local/bin/mock-mfa-{{ item }}"
+ dest: "/usr/local/bin/mock-mfa-{{ item }}"
+ owner: root
+ group: root
+ mode: "0755"
+ loop:
+ - sms
+ - email
+ when: devtesting_mock_mfa | bool
+
+- name: Remove mock MFA delivery helpers when mock MFA is disabled
+ ansible.builtin.file:
+ path: "/usr/local/bin/mock-mfa-{{ item }}"
+ state: absent
+ loop:
+ - sms
+ - email
+ when: not (devtesting_mock_mfa | bool)
+
+- name: Remove obsolete generic mock MFA helper
+ ansible.builtin.file:
+ path: /usr/local/bin/mock-mfa
+ state: absent
+
- name: Ensure Ansible facts directory exists
ansible.builtin.file:
path: "/etc/ansible/facts.d/"
diff --git a/roles/challenger/templates/etc/challenger/challenger-email.conf.j2 b/roles/challenger/templates/etc/challenger/challenger-email.conf.j2
@@ -12,7 +12,11 @@ UNIXPATH_MODE = 666
# Which external command should be used to transmit challenges?
# Example commands are challenger-send-{sms,email,post}
+{% if devtesting_mock_mfa | bool %}
+AUTH_COMMAND = /usr/local/bin/mock-mfa-email
+{% else %}
AUTH_COMMAND = /usr/bin/challenger-send-email
+{% endif %}
# How long is an individual validation request valid?
VALIDATION_DURATION = 7d
diff --git a/roles/challenger/templates/etc/challenger/challenger-sms.conf.j2 b/roles/challenger/templates/etc/challenger/challenger-sms.conf.j2
@@ -12,7 +12,11 @@ UNIXPATH_MODE = 666
# Which external command should be used to transmit challenges?
# Example commands are challenger-send-{sms,email,post}
+{% if devtesting_mock_mfa | bool %}
+AUTH_COMMAND = /usr/local/bin/mock-mfa-sms
+{% else %}
AUTH_COMMAND = /usr/bin/challenger-send-sms
+{% endif %}
# Name of a file with the message to send with the challenge.
MESSAGE_TEMPLATE_FILE = /etc/challenger/sms-message-template.txt
diff --git a/roles/exchange/templates/etc/nginx/sites-available/exchange-nginx.conf.j2 b/roles/exchange/templates/etc/nginx/sites-available/exchange-nginx.conf.j2
@@ -31,4 +31,12 @@ server {
alias /var/www/downloads/;
autoindex off;
}
+
+{% if devtesting_mock_mfa | bool %}
+ # Development testing only: expose the MFA messages written by mock-mfa.
+ location /mock-mfa/ {
+ alias /var/www/mock-mfa/;
+ autoindex on;
+ }
+{% endif %}
}