Files
rust-xmpp/tests/android_sasl_network.py
2026-10-20 15:59:55 +07:00

198 lines
9.9 KiB
Python

#!/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 = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>"
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'</stream:features>')
self.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
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'</stream:features>')
raw = raw[raw.index(b'<stream:features'):]
self.features = ET.fromstring(b"<root xmlns:stream='http://etherx.jabber.org/streams'>" + raw + b'</root>')
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"<authenticate xmlns='{ns}' mechanism='{mechanism}'><initial-response>{initial}</initial-response></authenticate>")
else:
self.send(f"<auth xmlns='{ns}' mechanism='{mechanism}'>{initial}</auth>")
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"<response xmlns='{ns}'>{b64((final + ',p=' + b64(proof)).encode())}</response>")
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:])