#!/usr/bin/env python3 """Independent Android SCRAM compatibility regression over real STARTTLS. Usage: python3 tests/android_sasl_network.py BINARY [--expect-060-failure] The selector mirrors Conversations ChannelBinding.best (Codeberg master, retrieved 2026-10-20). TLS 1.3 network cases use the certificate binding; the faithful Android selector's exporter preference is checked separately. """ import base64 import hashlib import hmac import json import itertools import socket import ssl import subprocess import sys import tempfile import time import xml.etree.ElementTree as ET from pathlib import Path SASL = 'urn:ietf:params:xml:ns:xmpp-sasl' SASL2 = 'urn:xmpp:sasl:2' CB = 'urn:xmpp:sasl-cb:0' OPEN = "" def android_binding(bindings, version): if version == 'TLSv1.3' and 'tls-exporter' in bindings: return 'tls-exporter' if version in ('TLSv1', 'TLSv1.1', 'TLSv1.2') and 'tls-unique' in bindings: return 'tls-unique' if 'tls-server-end-point' in bindings: return 'tls-server-end-point' if not bindings: return 'tls-exporter' if version == 'TLSv1.3' else 'tls-unique' return None def b64(data): return base64.b64encode(data).decode() class Connection: def __init__(self, port, cert, version): self.sock = socket.create_connection(('127.0.0.1', port), timeout=5) self.buffer = b'' self.send(OPEN) self.until(b'') self.send("") assert b'proceed' in self.until(b'/>') context = ssl.create_default_context(cafile=str(cert)) context.minimum_version = context.maximum_version = version self.sock = context.wrap_socket(self.sock, server_hostname='localhost') self.send(OPEN) raw = self.until(b'') raw = raw[raw.index(b'" + raw + b'') def send(self, value): self.sock.sendall(value.encode()) def until(self, marker): while marker not in self.buffer: chunk = self.sock.recv(65536) assert chunk, self.buffer self.buffer += chunk end = self.buffer.index(marker) + len(marker) result, self.buffer = self.buffer[:end], self.buffer[end:] return result def stanza(self): # SASL stanzas have no unclosed stream prefix after features. parser = ET.XMLPullParser(events=('start', 'end')) depth = 0 while True: byte = self.until(b'>') parser.feed(byte) if hasattr(parser, 'flush'): parser.flush() for event, node in parser.read_events(): depth += 1 if event == 'start' else -1 if event == 'end' and depth == 0: return node def auth(self, hash_name, sasl2, wrong_binding=False, expect_old=False, ssdp=True): ns = SASL2 if sasl2 else SASL parent = self.features.find('.//{' + ns + '}' + ('authentication' if sasl2 else 'mechanisms')) mechanisms = [n.text for n in parent.findall('{' + ns + '}mechanism')] bindings = [n.get('type') for n in self.features.findall('.//{' + CB + '}channel-binding')] selected = android_binding(bindings, self.sock.version()) if expect_old: assert bindings == ['tls-exporter'] and selected is None, bindings elif self.sock.version() == 'TLSv1.3': assert selected == 'tls-exporter', bindings # CPython does not expose TLS exporters. Exercise another real PLUS # binding on TLS 1.3, while retaining the selector assertion above. assert 'tls-server-end-point' in bindings selected = 'tls-server-end-point' else: assert selected == 'tls-server-end-point', bindings mechanism = 'SCRAM-' + hash_name + ('-PLUS' if selected else '') assert mechanism in mechanisms gs2 = ('p=' + selected + ',,') if selected else 'y,,' bare = 'n=alice,r=android-independent-client-nonce' initial = b64((gs2 + bare).encode()) if sasl2: self.send(f"{initial}") else: self.send(f"{initial}") challenge = self.stanza() if expect_old: assert challenge.tag == '{' + ns + '}failure', ET.tostring(challenge) assert challenge.find('{' + SASL + '}not-authorized') is not None return assert challenge.tag == '{' + ns + '}challenge', ET.tostring(challenge) first = base64.b64decode(challenge.text).decode() attrs = dict(item.split('=', 1) for item in first.split(',')) digest = hash_name.lower().replace('-', '') expected_ssdp = '\x1e'.join(sorted(mechanisms)) + '\x1f' + '\x1e'.join(sorted(bindings)) if ssdp: assert attrs['h'] == b64(hashlib.new(digest, expected_ssdp.encode()).digest()) else: assert 'h' not in attrs assert attrs['r'].startswith(bare.split('r=', 1)[1]) assert int(attrs['i']) >= 4096 # openssl req creates a SHA256-signed certificate; RFC 5929 hashes DER. binding = hashlib.sha256(self.sock.getpeercert(binary_form=True)).digest() if wrong_binding: binding = bytes([binding[0] ^ 1]) + binding[1:] final = 'c=' + b64(gs2.encode() + binding) + ',r=' + attrs['r'] salted = hashlib.pbkdf2_hmac(digest, b'secret', base64.b64decode(attrs['s']), int(attrs['i'])) mac = lambda key, message: hmac.new(key, message, digest).digest() client_key = mac(salted, b'Client Key') auth = (bare + ',' + first + ',' + final).encode() signature = mac(hashlib.new(digest, client_key).digest(), auth) proof = bytes(a ^ b for a, b in zip(client_key, signature)) self.send(f"{b64((final + ',p=' + b64(proof)).encode())}") result = self.stanza() if wrong_binding: assert result.tag == '{' + ns + '}failure', ET.tostring(result) assert result.find('{' + SASL + '}not-authorized') is not None else: assert result.tag == '{' + ns + '}success', ET.tostring(result) payload = result.findtext('{' + ns + '}additional-data') if sasl2 else result.text expected = 'v=' + b64(mac(mac(salted, b'Server Key'), auth)) assert base64.b64decode(payload).decode() == expected def run(binary, old=False): assert android_binding(['tls-exporter'], 'TLSv1.2') is None assert android_binding(['tls-exporter'], 'TLSv1.3') == 'tls-exporter' for hash_name, ssdp in itertools.product(('SHA-1', 'SHA-256'), (True,) if old else (False, True)): with tempfile.TemporaryDirectory(prefix='xmpp-android-sasl-') as tmp: root = Path(tmp) subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) port = probe.getsockname()[1] cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2'] + (['sasl_ssdp'] if ssdp else [])}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': hash_name, 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}} config = root/'config.json' config.write_text(json.dumps(cfg)) subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) with (root/'server.log').open('w+') as log: process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) try: for _ in range(100): assert process.poll() is None, 'server stopped' try: with socket.create_connection(('127.0.0.1', port), timeout=.1): break except OSError: time.sleep(.05) versions = [ssl.TLSVersion.TLSv1_2] if old else [ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3] for version in versions: for sasl2 in (False, True): for wrong in ([False] if old else [False, True]): connection = Connection(port, root/'cert.pem', version) try: connection.auth(hash_name, sasl2, wrong, old, ssdp) finally: connection.sock.close() except BaseException: log.flush(); log.seek(0); print(log.read()) raise finally: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill(); process.wait() print('PASS: reproduced 0.6.0 TLS1.2 Android y,, rejection' if old else 'PASS: Android TLS1.2 selector, TLS1.2/1.3 SCRAM SHA1/256 PLUS, SASL1/2, SSDP on/off and invalid binding rejection') if __name__ == '__main__': run(str(Path(sys.argv[1]).resolve()), '--expect-060-failure' in sys.argv[2:])