#!/usr/bin/env python3
"""Real STARTTLS regression for SASL identity preparation and exchange retries.
Usage: python3 tests/sasl_retry_network.py BINARY
"""
import json
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from android_sasl_network import Connection, OPEN, SASL, SASL2, b64
BIND = 'urn:ietf:params:xml:ns:xmpp-bind'
def authenticate(connection, sasl2, mechanism, payload):
ns = SASL2 if sasl2 else SASL
encoded = b64(payload.encode())
if sasl2:
connection.send(f"{encoded}")
else:
connection.send(f"{encoded}")
def plain(connection, sasl2, username='alice', authz=''):
authenticate(connection, sasl2, 'PLAIN', f'{authz}\0{username}\0secret')
def expect_failure(connection, sasl2, condition):
result = connection.stanza()
ns = SASL2 if sasl2 else SASL
assert result.tag == '{' + ns + '}failure', ET.tostring(result)
assert result.find('{' + SASL + '}' + condition) is not None, ET.tostring(result)
def expect_canonical_bind(connection, sasl2):
result = connection.stanza()
ns = SASL2 if sasl2 else SASL
assert result.tag == '{' + ns + '}success', ET.tostring(result)
if sasl2:
assert result.findtext('{' + SASL2 + '}authorization-identifier') == 'alice@localhost', ET.tostring(result)
else:
connection.send(OPEN)
connection.until(b'')
connection.send(f"retry-test")
result = connection.stanza()
assert result.get('type') == 'result' and result.get('id') == 'bind', ET.tostring(result)
assert result.findtext('.//{' + BIND + '}jid') == 'alice@localhost/retry-test', ET.tostring(result)
def start_scram(connection, sasl2):
authenticate(connection, sasl2, 'SCRAM-SHA-256', 'n,,n=alice,r=retry-regression-nonce')
result = connection.stanza()
ns = SASL2 if sasl2 else SASL
assert result.tag == '{' + ns + '}challenge', ET.tostring(result)
def check_case(connection, sasl2, case):
if case in ('uppercase', 'fullwidth'):
user = 'ALICE' if case == 'uppercase' else 'Alice'
plain(connection, sasl2, user, user + '@localhost')
elif case == 'invalid-authz':
plain(connection, sasl2, 'ALICE', 'bob@localhost')
expect_failure(connection, sasl2, 'not-authorized')
plain(connection, sasl2)
elif case == 'abort':
start_scram(connection, sasl2)
ns = SASL2 if sasl2 else SASL
connection.send(f"")
expect_failure(connection, sasl2, 'aborted')
plain(connection, sasl2)
elif case == 'restart':
start_scram(connection, sasl2)
plain(connection, sasl2, 'ALICE')
else:
raise AssertionError(case)
expect_canonical_bind(connection, sasl2)
def run(binary):
cases = ('uppercase', 'fullwidth', 'invalid-authz', 'abort', 'restart')
with tempfile.TemporaryDirectory(prefix='xmpp-sasl-retry-') 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']}, '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, capture_output=True)
with (root/'server.log').open('w+') as log:
process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
active = None
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)
for version in (ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3):
for sasl2 in (False, True):
for case in cases:
active = (version.name, 'SASL2' if sasl2 else 'SASL1', case)
connection = Connection(port, root/'cert.pem', version)
try:
check_case(connection, sasl2, case)
finally:
connection.sock.close()
except BaseException:
print('FAILED case:', active)
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: 20 TLS1.2/1.3 SASL1/2 cases: prepared PLAIN identities and canonical binds, invalid authz rejection, SCRAM abort/retry and active exchange restart')
if __name__ == '__main__':
run(str(Path(sys.argv[1]).resolve()))