commit ad3e519f37747ac7bb64ca3a8aecf5aa2baef1c1
parent 293607ced2ef241f57a9db8aa88916bd356b0186
Author: Florian Dold <florian@dold.me>
Date: Mon, 6 Jul 2026 23:06:11 +0200
devtesting for challenger
Diffstat:
4 files changed, 331 insertions(+), 0 deletions(-)
diff --git a/roles/devtesting/tasks/files/etc/sudoers.d/devtesting b/roles/devtesting/tasks/files/etc/sudoers.d/devtesting
@@ -1 +1,2 @@
devtesting ALL=(libeufin-nexus:libeufin-nexus) CWD=/tmp/ NOPASSWD: /usr/bin/libeufin-nexus testing *
+devtesting ALL=(taler-exchange-httpd:taler-exchange-kyc) CWD=/tmp/ NOPASSWD: /usr/local/bin/taler-devtesting-exchange-challenger-setup *
diff --git a/roles/devtesting/tasks/files/taler-devtesting b/roles/devtesting/tasks/files/taler-devtesting
@@ -80,6 +80,21 @@ def geniban():
]
)
+@cli.command()
+@click.argument('section', nargs=1, type=click.STRING)
+def challenger(section):
+ subprocess.run(
+ [
+ "sudo",
+ "-u",
+ "taler-exchange-httpd",
+ "taler-devtesting-exchange-challenger-setup",
+ "-c",
+ "/etc/taler-exchange/taler-exchange.conf",
+ section
+ ],
+ check=True,
+ )
if __name__ == "__main__":
orig_cmd = os.environ.get("SSH_ORIGINAL_COMMAND")
diff --git a/roles/devtesting/tasks/files/taler-devtesting-exchange-challenger-setup b/roles/devtesting/tasks/files/taler-devtesting-exchange-challenger-setup
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""
+Taler Challenger Address Verification Setup Tool
+=================================================
+This script reads the configuration for a KYC provider section from a taler-exchange config,
+sends a setup request to the Challenger API, and returns the address verification URL.
+"""
+
+import argparse
+import json
+import logging
+import secrets
+import shutil
+import subprocess
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+
+def list_sections(config_file):
+ """List available configuration sections using taler-exchange-config."""
+ cmd = ["taler-exchange-config"]
+ if config_file:
+ cmd.extend(["-c", config_file])
+ cmd.append("-S")
+ try:
+ res = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ lines = res.stdout.strip().split("\n")
+ sections = []
+ for line in lines:
+ line = line.strip()
+ if line and not line.startswith("The following sections are available"):
+ sections.append(line)
+ return sections
+ except subprocess.CalledProcessError as e:
+ print(f"Warning: failed to list sections: {e.stderr.strip()}", file=sys.stderr)
+ return []
+
+
+def get_config_val(config_file, section, option):
+ """Retrieve a configuration value using taler-exchange-config."""
+ cmd = ["taler-exchange-config"]
+ if config_file:
+ cmd.extend(["-c", config_file])
+ cmd.extend(["-s", section, "-o", option])
+ res = subprocess.run(cmd, capture_output=True, text=True)
+ if res.returncode == 0:
+ return res.stdout.strip()
+ return None
+
+
+def resolve_section(config_file, section_arg):
+ """Resolve the section name by checking exact matches and kyc-provider prefixes/suffixes."""
+ sections = list_sections(config_file)
+ if not sections:
+ return section_arg
+
+ # Try exact match
+ if section_arg in sections:
+ return section_arg
+
+ # Try prepending 'kyc-provider-'
+ prefixed = f"kyc-provider-{section_arg}"
+ if prefixed in sections:
+ return prefixed
+
+ # Try suffixing with '-kyc-provider'
+ suffixed = f"{section_arg}-kyc-provider"
+ if suffixed in sections:
+ return suffixed
+
+ # Try finding any section containing section_arg
+ matches = [s for s in sections if section_arg in s]
+ if len(matches) == 1:
+ return matches[0]
+ elif len(matches) > 1:
+ print(f"Warning: Ambiguous section name '{section_arg}'. Matches: {', '.join(matches)}", file=sys.stderr)
+
+ return section_arg
+
+
+def main():
+ if not shutil.which("taler-exchange-config"):
+ print("Error: 'taler-exchange-config' command not found in PATH.", file=sys.stderr)
+ sys.exit(1)
+
+ parser = argparse.ArgumentParser(
+ description="Initiate a Challenger address verification process using taler-exchange configuration."
+ )
+ parser.add_argument(
+ "section",
+ help="The configuration section name (with or without 'kyc-provider-' prefix/suffix, e.g., 'postal-challenger')"
+ )
+ parser.add_argument(
+ "-c", "--config",
+ help="Path to the taler-exchange configuration file (e.g., challenger-test.conf)"
+ )
+ parser.add_argument(
+ "-r", "--redirect-uri",
+ help="OAuth2 redirect URI (falls back to exchange BASE_URL/kyc-proof/<provider_name>)"
+ )
+ parser.add_argument(
+ "-s", "--state",
+ help="OAuth2 state parameter (default: cryptographically secure random string)"
+ )
+ parser.add_argument(
+ "-o", "--read-only",
+ action="store_true",
+ help="Instruct Challenger to prevent editing of the pre-populated address"
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Output results in JSON format"
+ )
+ parser.add_argument(
+ "-v", "--verbose",
+ action="store_true",
+ help="Log verbose HTTP request/response details"
+ )
+ parser.add_argument(
+ "--address-json",
+ help="Raw JSON object containing fields to pre-populate the address"
+ )
+
+ # Individual address fields for convenience
+ parser.add_argument("--email", help="Pre-populate CONTACT_EMAIL (for email validation)")
+ parser.add_argument("--phone", help="Pre-populate CONTACT_PHONE (for phone validation)")
+ parser.add_argument("--name", help="Pre-populate CONTACT_NAME (for postal validation)")
+ parser.add_argument("--address-lines", help="Pre-populate ADDRESS_LINES (for postal validation)")
+ parser.add_argument("--country", help="Pre-populate ADDRESS_COUNTRY (for postal validation)")
+
+ args = parser.parse_args()
+
+ # Configure logging
+ log_level = logging.DEBUG if args.verbose else logging.INFO
+ logging.basicConfig(
+ level=log_level,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ stream=sys.stderr
+ )
+
+ # Resolve section name
+ section = resolve_section(args.config, args.section)
+
+ # Read required OAuth2 configurations
+ client_id = get_config_val(args.config, section, "KYC_OAUTH2_CLIENT_ID")
+ client_secret = get_config_val(args.config, section, "KYC_OAUTH2_CLIENT_SECRET")
+ authorize_url = get_config_val(args.config, section, "KYC_OAUTH2_AUTHORIZE_URL")
+
+ missing = []
+ if not client_id:
+ missing.append("KYC_OAUTH2_CLIENT_ID")
+ if not client_secret:
+ missing.append("KYC_OAUTH2_CLIENT_SECRET")
+ if not authorize_url:
+ missing.append("KYC_OAUTH2_AUTHORIZE_URL")
+
+ if missing:
+ print(f"Error: Missing required options in config section [{section}]: {', '.join(missing)}", file=sys.stderr)
+ sys.exit(1)
+
+ # Construct the Setup URL
+ # E.g. https://postal.challenger.stage.taler-ops.ch/authorize#setup -> https://postal.challenger.stage.taler-ops.ch/setup/1
+ url_no_frag = authorize_url.split('#')[0]
+ if '/' not in url_no_frag:
+ print(f"Error: Invalid KYC_OAUTH2_AUTHORIZE_URL: {authorize_url}", file=sys.stderr)
+ sys.exit(1)
+ base_part = url_no_frag.rsplit('/', 1)[0]
+ setup_url = f"{base_part}/setup/{client_id}"
+
+ # Determine redirect URI
+ redirect_uri = args.redirect_uri
+ if not redirect_uri:
+ exchange_base_url = get_config_val(args.config, "exchange", "BASE_URL")
+ if exchange_base_url:
+ provider_name = section
+ if provider_name.startswith("kyc-provider-"):
+ provider_name = provider_name[len("kyc-provider-"):]
+ if not exchange_base_url.endswith("/"):
+ exchange_base_url += "/"
+ redirect_uri = f"{exchange_base_url}kyc-proof/{provider_name}"
+
+ # Determine state parameter
+ state = args.state if args.state else secrets.token_hex(16)
+
+ # Build Challenger setup request payload
+ payload = {"read_only": args.read_only}
+
+ if args.address_json:
+ try:
+ json_data = json.loads(args.address_json)
+ payload.update(json_data)
+ except json.JSONDecodeError as e:
+ print(f"Error: Invalid JSON payload in --address-json: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if args.email:
+ payload["CONTACT_EMAIL"] = args.email
+ if args.phone:
+ payload["CONTACT_PHONE"] = args.phone
+ if args.name:
+ payload["CONTACT_NAME"] = args.name
+ if args.address_lines:
+ payload["ADDRESS_LINES"] = args.address_lines
+ if args.country:
+ payload["ADDRESS_COUNTRY"] = args.country
+
+ # Optional scope configuration
+ scope = get_config_val(args.config, section, "KYC_OAUTH2_SCOPE")
+
+ # Make the HTTP setup POST request to Challenger
+ req_data = json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(
+ setup_url,
+ data=req_data,
+ headers={
+ "Authorization": f"Bearer {client_secret}",
+ "Content-Type": "application/json"
+ },
+ method="POST"
+ )
+
+ logging.info(f"Making POST request to {setup_url}")
+ # Redact client secret in headers for safety
+ headers_logged = dict(req.headers)
+ if 'Authorization' in headers_logged:
+ headers_logged['Authorization'] = 'Bearer <redacted>'
+ logging.debug(f"Request Headers: {headers_logged}")
+ logging.debug(f"Request Body: {payload}")
+
+ try:
+ with urllib.request.urlopen(req) as response:
+ resp_bytes = response.read()
+ logging.info(f"Received HTTP {response.status} from Challenger")
+ logging.debug(f"Response Headers: {dict(response.getheaders())}")
+ logging.debug(f"Response Body: {resp_bytes.decode('utf-8')}")
+ resp_json = json.loads(resp_bytes.decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ err_bytes = e.read()
+ err_msg = err_bytes.decode("utf-8")
+ logging.error(f"HTTP setup request failed with status code {e.code}: {e.reason}")
+ logging.debug(f"Response Headers: {dict(e.headers)}")
+ logging.debug(f"Response Body: {err_msg}")
+
+ print(f"Error: HTTP setup request failed with status code {e.code}: {e.reason}", file=sys.stderr)
+ try:
+ err_json = json.loads(err_msg)
+ print(json.dumps(err_json, indent=2), file=sys.stderr)
+ except json.JSONDecodeError:
+ if err_msg:
+ print(err_msg, file=sys.stderr)
+ sys.exit(1)
+ except urllib.error.URLError as e:
+ logging.error(f"Network connection failed: {e.reason}")
+ print(f"Error: Network connection failed to {setup_url}: {e.reason}", file=sys.stderr)
+ sys.exit(1)
+
+ nonce = resp_json.get("nonce")
+ if not nonce:
+ print("Error: Challenger response did not contain a 'nonce'.", file=sys.stderr)
+ print(json.dumps(resp_json, indent=2), file=sys.stderr)
+ sys.exit(1)
+
+ # Build redirect / authorization URL
+ auth_base = f"{url_no_frag}/{nonce}" if not url_no_frag.endswith("/") else f"{url_no_frag}{nonce}"
+ query_params = {
+ "response_type": "code",
+ "client_id": client_id,
+ "state": state
+ }
+ if redirect_uri:
+ query_params["redirect_uri"] = redirect_uri
+ if scope:
+ query_params["scope"] = scope
+
+ encoded_params = urllib.parse.urlencode(query_params)
+ auth_url = f"{auth_base}?{encoded_params}"
+
+ if args.json:
+ out = {
+ "setup_url": setup_url,
+ "nonce": nonce,
+ "client_id": client_id,
+ "redirect_uri": redirect_uri,
+ "state": state,
+ "scope": scope,
+ "authorization_url": auth_url
+ }
+ print(json.dumps(out, indent=2))
+ else:
+ print("Successfully initiated address verification with Challenger!")
+ print(f"Challenger Setup URL: {setup_url}")
+ print(f"Returned Nonce: {nonce}")
+ print(f"Client ID: {client_id}")
+ print(f"Redirect URI: {redirect_uri if redirect_uri else 'Not specified'}")
+ print(f"State: {state}")
+ if scope:
+ print(f"Scope: {scope}")
+ print("\nTo complete address verification, open the following URL in your browser:")
+ print(auth_url)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/roles/devtesting/tasks/main.yml b/roles/devtesting/tasks/main.yml
@@ -28,6 +28,15 @@
group: root
mode: "0755"
+
+- name: Place challenger devtesting helper
+ ansible.builtin.copy:
+ src: files/taler-devtesting-exchange-challenger-setup
+ dest: /usr/local/bin/taler-devtesting-exchange-challenger-setup
+ owner: root
+ group: root
+ mode: "0755"
+
- name: Grant sudo rights to devtesting user
ansible.builtin.copy:
src: etc/sudoers.d/devtesting