#!/usr/bin/env python3
"""Isolated TLS regression for Gajim's XEP-0258/XEP-0077 predicates and operations.
Usage: python3 tests/gajim_discovery_network.py [target/release/rust-xmpp]
Only temporary accounts, certificates, and server data are used.
"""
import base64
import json
from pathlib import Path
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from _wire_client import BIN, Client, OPEN
DISCO = 'http://jabber.org/protocol/disco#info'
REGISTER = 'jabber:iq:register'
LABEL = 'urn:xmpp:sec-label:0'
CATALOGS = ('urn:xmpp:sec-label:catalog:2', 'urn:xmpp:sec-label:catalog:0')
STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas'
class Unauthenticated(Client):
def __init__(self, port, cert):
self.sock = socket.create_connection(('127.0.0.1', port), timeout=5)
self.buf = b''
self.queue = []
self.send(OPEN)
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(OPEN)
self.features = self.until(b'')
def iq(client, identifier, payload, target='localhost', typ='get', error=False):
client.send(f"{payload}")
return client.iq(identifier, error=error)
def error_condition(response, expected):
assert response.find(f'{{jabber:client}}error/{{{STANZAS}}}{expected}') is not None, ET.tostring(response)
def check_authenticated(client, enabled):
for target in ('localhost', 'alice@localhost'):
response = iq(client, 'disco-' + target, f"", target)
features = {node.get('var') for node in response.iter(f'{{{DISCO}}}feature')}
for namespace in (LABEL, REGISTER, CATALOGS[0]):
assert (namespace in features) == enabled, (target, enabled, namespace, features)
for index, namespace in enumerate(CATALOGS):
for target in ('localhost', 'alice@localhost'):
response = iq(client, f'catalog-{index}-{target}',
f"", target,
error=not enabled)
if not enabled:
error_condition(response, 'service-unavailable')
continue
catalog = response.find(f'{{{namespace}}}catalog')
assert catalog is not None, ET.tostring(response)
assert catalog.get('to') == 'bob@localhost', ET.tostring(response)
entry = catalog.find(f'{{{namespace}}}item') if index == 0 else catalog.find(f'{{{LABEL}}}securitylabel')
assert entry is not None, ET.tostring(response)
assert entry.get('selector') == 'Public' and entry.get('default') == 'true', ET.tostring(response)
label = entry.find(f'{{{LABEL}}}securitylabel') if index == 0 else entry
assert label.find(f'{{{LABEL}}}label').text == 'PUBLIC', ET.tostring(response)
response = iq(client, 'account-registration', f"", error=not enabled)
if enabled:
query = response.find(f'{{{REGISTER}}}query')
assert query is not None and query.find(f'{{{REGISTER}}}registered') is not None, ET.tostring(response)
assert query.find(f'{{{REGISTER}}}username').text == 'alice', ET.tostring(response)
iq(client, 'change-password',
f"alicechanged-secret", typ='set')
else:
error_condition(response, 'service-unavailable')
response = iq(client, 'change-password-disabled',
f"changed-secret",
typ='set', error=True)
error_condition(response, 'service-unavailable')
def scenario(binary, root, enabled, allow_registration):
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
name = f'enabled-{enabled}-registration-{allow_registration}'
modules = ['tls', 'saslauth', 'pep', 'disco', 'ping']
if enabled:
modules += ['seclabels', 'register']
cfg = {'global': {
'data_path': str(root / name), 'interfaces': ['127.0.0.1'],
'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [],
'modules_enabled': modules, 'allow_registration': allow_registration,
'security_labels': [{'name': 'Public', 'label': 'PUBLIC', 'default': True}],
}, 'hosts': {'localhost': {'options': {
'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
'ssl': {'key': str(root / 'key.pem'), 'certificate': str(root / 'cert.pem')},
}}}}
config = root / f'{name}.json'
config.write_text(json.dumps(cfg))
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
input='original-secret\n', text=True, check=True, capture_output=True)
with (root / f'{name}.log').open('w+') as log:
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
clients = []
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')
client = Client(port, root / 'cert.pem', 'alice', 'original-secret')
clients.append(client)
check_authenticated(client, enabled)
# An actual fresh SASL login proves the password update was persisted.
logged_in = Client(port, root / 'cert.pem', 'alice',
'changed-secret' if enabled else 'original-secret', 'new-login')
clients.append(logged_in)
logged_in.sync()
unauth = Unauthenticated(port, root / 'cert.pem')
clients.append(unauth)
registration_enabled = enabled and allow_registration
assert (b'http://jabber.org/features/iq-register' in unauth.features) == registration_enabled, unauth.features
response = iq(unauth, 'ibr-form', f"", error=not registration_enabled)
if registration_enabled:
query = response.find(f'{{{REGISTER}}}query')
assert query is not None and query.find(f'{{{REGISTER}}}username') is not None, ET.tostring(response)
else:
error_condition(response, 'forbidden')
response = iq(unauth, 'ibr-create',
f"newuseribr-secret",
typ='set', error=not registration_enabled)
if registration_enabled:
registered = Client(port, root / 'cert.pem', 'newuser', 'ibr-secret', 'ibr-login')
clients.append(registered)
registered.sync()
# XEP-0077 removal must acknowledge the request and revoke login.
iq(registered, 'ibr-remove', f"", typ='set')
deleted_login = Unauthenticated(port, root / 'cert.pem')
clients.append(deleted_login)
credentials = base64.b64encode(b'\0newuser\0ibr-secret').decode()
deleted_login.send(f"{credentials}")
assert b'failure' in deleted_login.until(b'>')
assert not list((root / name).glob('*/accounts/newuser.dat'))
else:
error_condition(response, 'forbidden')
# Rejection must not create an account on disk.
assert not list((root / name).glob('*/accounts/newuser.dat'))
print('PASS:', name)
except BaseException:
log.flush()
log.seek(0)
print(log.read(), file=sys.stderr)
raise
finally:
for client in clients:
client.close()
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
def run(binary):
with tempfile.TemporaryDirectory(prefix='xmpp-gajim-discovery-') as tmp:
root = Path(tmp)
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)
for enabled in (False, True):
for allow_registration in (False, True):
scenario(binary, root, enabled, allow_registration)
print('PASS: Gajim domain/account discovery, both label catalogs, authenticated password changes, IBR registration gates and fresh logins')
if __name__ == '__main__':
run(str(Path(sys.argv[1]).resolve()) if len(sys.argv) > 1 else str(BIN))