Files
2026-10-20 15:59:55 +07:00

145 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Independent TLS regression for SASL2 inline SM resume. Usage: script BINARY."""
import json
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from sasl2_bind import Client as BindClient, SASL2, BIND2, SASL
SM = 'urn:xmpp:sm:3'
class Client(BindClient):
def until(self, marker):
xml = super().until(marker)
if b'<authentication' in xml:
features = xml[xml.index(b'<stream:features'):]
root = ET.fromstring(b"<root xmlns:stream='http://etherx.jabber.org/streams'>" + features + b'</root>')
assert root.find('.//{' + SASL2 + '}inline/{' + SM + '}sm') is not None, features
return xml
def ping(self, ident):
self.send(f"<iq type='get' id='{ident}' to='localhost'><ping xmlns='urn:xmpp:ping'/></iq>")
result = self.until(b'</iq>')
assert b"type='result'" in result and f"id='{ident}'".encode() in result, result
return result
def run(binary):
with tempfile.TemporaryDirectory(prefix='xmpp-sasl2-resume-') 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', 'carbons', 'csi_simple', 'smacks', 'ping']}, '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))
for user in ['alice', 'bob']:
subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
clients = []
def connect():
client = Client(port, root/'cert.pem')
clients.append(client)
return client
def request(token, h=0):
return f"<resume xmlns='{SM}' previd='{token}' h='{h}'/>"
bind = f"<bind xmlns='{BIND2}'><tag>Phone</tag><enable xmlns='{SM}' resume='true'/></bind>"
def assert_resume(response, token, jid):
assert response.find(f'{{{SASL2}}}authorization-identifier').text == jid, ET.tostring(response)
resumed = response.find(f'{{{SM}}}resumed')
assert resumed is not None and resumed.get('previd') == token, ET.tostring(response)
assert response.find(f'{{{BIND2}}}bound') is None, ET.tostring(response)
assert response.find(f'{{{SM}}}failed') is None, ET.tostring(response)
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')
old = connect()
response = old.auth(bind)
jid = response.find(f'{{{SASL2}}}authorization-identifier').text
token = response.find(f'{{{BIND2}}}bound/{{{SM}}}enabled').get('id')
old.until(b'</stream:features>')
old.ping('acknowledged')
old.ping('pending')
# Wrong credentials must not take over an active session.
bad = connect()
failure = bad.auth(request(token) + bind, password='wrong', good=False)
assert failure.find(f'{{{SASL}}}not-authorized') is not None
old.send(f"<r xmlns='{SM}'/>")
assert ET.fromstring(old.until(b'/>')).tag == f'{{{SM}}}a'
# Wrong owner, unknown id and impossible acknowledgement fail inside
# success and retain optional Bind2 fallback. Failed requests leave
# the original session and its pending queue untouched.
for user, ident, h, fallback in [('bob', token, 0, True), ('alice', 'missing', 0, True), ('alice', token, 99, False)]:
failed = connect()
response = failed.auth(request(ident, h) + (bind if fallback else ''), user=user)
assert response.find(f'{{{SM}}}failed') is not None, ET.tostring(response)
assert response.find(f'{{{SM}}}resumed') is None
assert (response.find(f'{{{BIND2}}}bound') is not None) == fallback
authjid = response.find(f'{{{SASL2}}}authorization-identifier').text
assert authjid.startswith(user + '@localhost/') if fallback else authjid == user + '@localhost'
features = failed.until(b'</stream:features>')
assert (b'xmpp-bind' in features) != fallback
# Active transport takeover; Bind2 is ignored after successful resume.
fresh = connect()
response = fresh.auth(request(token, 1) + bind)
assert_resume(response, token, jid)
assert response.find(f'{{{SM}}}resumed').get('h') == '2'
# auth() parses success first: replay can never precede it. Only the
# second response remains unacknowledged and must be replayed once.
replay = fresh.until(b'</iq>')
assert b"id='pending'" in replay and b'acknowledged' not in replay, replay
features = fresh.until(b'</stream:features>')
assert b'xmpp-bind' not in features and b"<sm " not in features, features
conflict = old.until(b'</stream:stream>')
assert b'conflict' in conflict, conflict
fresh.send(f"<a xmlns='{SM}' h='2'/><r xmlns='{SM}'/>")
assert ET.fromstring(fresh.until(b'/>')).tag == f'{{{SM}}}a'
# EOF suspension and replay-free second resume, without Bind2.
fresh.close()
again = connect()
response = again.auth(request(token, 2))
assert_resume(response, token, jid)
assert b'xmpp-bind' not in again.until(b'</stream:features>')
again.ping('after-resume')
malformed = [request(token) * 2, f"<resume xmlns='{SM}' previd='{token}' h='bad'/>", f"<resume xmlns='{SM}' h='0'/>", f"<resume xmlns='{SM}' previd='{token}' h='0'><nested/></resume>"]
for inline in malformed:
client = connect()
failure = client.auth(inline, good=False)
assert failure.find(f'{{{SASL}}}malformed-request') is not None, ET.tostring(failure)
response = client.auth()
assert response.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost'
client.until(b'</stream:features>')
again.ping('survives-malformed')
print('PASS: SASL2 inline SM active/EOF resume, authenticated owner/id/h checks, replay ordering, full JID, Bind2 fallback and malformed requests')
finally:
for client in clients:
client.close()
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()))