#!/usr/bin/env python3 """Independent TLS/SASL2 Bind2 network regression. Usage: python3 tests/sasl2_bind.py BINARY.""" import base64 import json import socket import ssl import subprocess import sys import tempfile import time import uuid import xml.etree.ElementTree as ET from pathlib import Path SASL2 = 'urn:xmpp:sasl:2' BIND2 = 'urn:xmpp:bind:0' SASL = 'urn:ietf:params:xml:ns:xmpp-sasl' STREAM = "" class Client: def __init__(self, port, cert): self.sock = socket.create_connection(('127.0.0.1', port), timeout=5) self.buffer = b'' self.send(STREAM) self.until(b'') self.send("") assert b'proceed' in self.until(b'/>') self.sock = ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock, server_hostname='localhost') self.send(STREAM) features = self.until(b'') features = features[features.index(b'" + features + b'') bind = root.find('.//{' + SASL2 + '}authentication/{' + SASL2 + '}inline/{' + BIND2 + '}bind') assert bind is not None, features advertised = {node.get('var') for node in bind.findall('{' + BIND2 + '}inline/{' + BIND2 + '}feature')} assert advertised == {'urn:xmpp:carbons:2', 'urn:xmpp:csi:0', 'urn:xmpp:sm:3'}, advertised def send(self, xml): self.sock.sendall(xml.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 auth(self, bind='', password='secret', good=True, user='alice', agent='private-device-id'): payload = base64.b64encode(('\0' + user + '\0' + password).encode()).decode() self.send(f"{payload}{bind}") return ET.fromstring(self.until(b'' if good else b'')) def close(self): self.sock.close() def run(binary): with tempfile.TemporaryDirectory(prefix='xmpp-bind2-') as tmp: root = Path(tmp) with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) port = probe.getsockname()[1] subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-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) cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'mam', 'carbons', 'csi_simple', 'smacks']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', '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, stdout=subprocess.DEVNULL) subprocess.run([binary, 'create-user', str(config), 'bob', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL) with (root/'server.log').open('w+') as log: server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) try: for _ in range(100): assert server.poll() is None, 'server stopped' try: with socket.create_connection(('127.0.0.1', port), timeout=.1): break except OSError: time.sleep(.05) else: raise AssertionError('server did not start') resources = [] for _ in range(2): c = Client(port, root/'cert.pem') try: response = c.auth(f"Phone&Tablet") jid = response.find('{' + SASL2 + '}authorization-identifier').text assert jid.startswith('alice@localhost/Phone&Tablet/'), jid assert 'private-device-id' not in jid resource = jid.split('/Phone&Tablet/')[1] assert uuid.UUID(resource).version == 4 resources.append(resource) bound = response.find('{' + BIND2 + '}bound') assert bound is not None assert bound.find('{urn:xmpp:mam:2}metadata') is not None assert bound.find('{urn:xmpp:carbons:2}enabled') is not None assert bound.find('{urn:xmpp:sm:3}enabled').get('resume') == 'true' assert b'xmpp-bind' not in c.until(b'') # No legacy bind IQ: the session is already usable and SM enabled. c.send("") assert ET.fromstring(c.until(b'/>')).tag == '{urn:xmpp:sm:3}a' finally: c.close() assert resources[0] != resources[1] invalid = [f"", f"ab", f"{'x'*987}", f"bad\ue000", f"", f""] for request in invalid: c = Client(port, root/'cert.pem') try: response = c.auth(request, good=False) assert response.find('{' + SASL + '}malformed-request') is not None, ET.tostring(response) # Malformed inline requests cannot leave a partially bound session. response = c.auth() assert response.find('{' + BIND2 + '}bound') is None assert response.find('{' + SASL2 + '}authorization-identifier').text == 'alice@localhost' assert b'xmpp-bind' in c.until(b'') finally: c.close() c = Client(port, root/'cert.pem') try: response = c.auth(f"", password='wrong', good=False) assert response.find('{' + SASL + '}not-authorized') is not None assert response.find('{' + BIND2 + '}bound') is None response = c.auth(f"") jid = response.find('{' + SASL2 + '}authorization-identifier').text assert uuid.UUID(jid.split('/', 1)[1]).version == 4 finally: c.close() # Device replacement affects only this account and valid UUIDv4 ids. connections = [] device = 'd4565fa7-4d72-4749-b3d3-740edbf87770' def bound_client(user, agent): client = Client(port, root/'cert.pem') connections.append(client) response = client.auth(f"", user=user, agent=agent) jid = response.find('{' + SASL2 + '}authorization-identifier').text assert agent not in jid client.until(b'') return client def ping(client): client.send("") assert ET.fromstring(client.until(b'/>')).tag == '{urn:xmpp:sm:3}a' try: other = bound_client('bob', device) old = bound_client('alice', device.upper()) invalid_first = bound_client('alice', 'not-a-uuid') invalid_second = bound_client('alice', 'not-a-uuid') ping(invalid_first) fresh = bound_client('alice', device) old.sock.settimeout(2) old.until(b'') assert old.sock.recv(1) == b'', 'old device stream remained open' for client in [fresh, other, invalid_first, invalid_second]: ping(client) finally: for client in connections: client.close() print('PASS: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback') finally: server.terminate() try: server.wait(timeout=5) except subprocess.TimeoutExpired: server.kill() server.wait() if server.returncode not in (0, -15): log.seek(0) print(log.read(), file=sys.stderr) if __name__ == '__main__': run(str(Path(sys.argv[1]).resolve()))