#!/usr/bin/env python3 """Monocles/Conversations PEP request sequences over real loopback STARTTLS. Usage: python3 tests/pep_client_network.py target/debug/rust-xmpp Payload shapes follow Monocles IqGenerator and AxolotlService: missing own bundle must return item-not-found (other errors latch isPepBroken), publishes omit `to`, and open-access preconditions recover through owner configuration. Key bytes are opaque transport fixtures, not usable cryptographic bundles. """ import json from pathlib import Path import socket import subprocess import sys import tempfile import time import xml.etree.ElementTree as ET from push_discovery import Client PUBSUB = 'http://jabber.org/protocol/pubsub' OWNER = PUBSUB + '#owner' ERROR = PUBSUB + '#errors' DATA = 'jabber:x:data' STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas' LEGACY = 'eu.siacs.conversations.axolotl' OMEMO = 'urn:xmpp:omemo:2' BUNDLES = OMEMO + ':bundles' def form(kind, fields): values = {'FORM_TYPE': PUBSUB + '#' + kind, **fields} return "" + ''.join( f"{value}" for key, value in values.items()) + '' def result(response): assert response.get('type') == 'result', ET.tostring(response) return response def condition(response, namespace, name): assert response.get('type') == 'error', ET.tostring(response) assert next(response.iter(f'{{{namespace}}}{name}'), None) is not None, ET.tostring(response) def fetch(client, node, item=None, identifier='fetch', target='alice@localhost'): selector = '' if item is None else f"" return client.iq(identifier, f"{selector}", target) def publish(client, node, item, payload, options=None, identifier='publish'): options_xml = '' if options is None else '' + form('publish-options', options) + '' # IqGenerator.publish deliberately does not set a destination. return client.iq(identifier, f"{payload}{options_xml}", typ='set') def check_items(client): for device in ('123', '456'): response = result(fetch(client, BUNDLES, device, 'bundle-' + device)) items = list(response.iter(f'{{{PUBSUB}}}item')) assert [item.get('id') for item in items] == [device], ET.tostring(response) bundle = next(response.iter(f'{{{OMEMO}}}bundle')) assert bundle.find(f'{{{OMEMO}}}spk').text == 'ZmFrZS1rZXk=' + device, ET.tostring(response) for node, namespace, element in ((LEGACY + '.devicelist', LEGACY, 'list'), (OMEMO + ':devices', OMEMO, 'devices')): response = result(fetch(client, node)) devices = next(response.iter(f'{{{namespace}}}{element}')) assert {d.get('id') for d in devices} == {'123', '456'}, ET.tostring(response) def sequence(client): # AxolotlService's first own-bundle fetch must never become service-unavailable. condition(fetch(client, BUNDLES, '123'), STANZAS, 'item-not-found') for node in (LEGACY + '.bundles:123', 'urn:missing:pep'): condition(fetch(client, node), STANZAS, 'item-not-found') for device in ('123', '456'): bundle = f"ZmFrZS1rZXk={device}c2lnaWs=cGs=" # OMEMO2 shares a node between devices; retain both records explicitly. options = {'pubsub#access_model': 'open', 'pubsub#max_items': 'max'} result(publish(client, BUNDLES, device, bundle, options)) for node, namespace, element in ((LEGACY + '.devicelist', LEGACY, 'list'), (OMEMO + ':devices', OMEMO, 'devices')): payload = f"<{element} xmlns='{namespace}'>" # Existing/migrated presence node: Monocles retries after pushing config. response = publish(client, node, 'current', payload, {'pubsub#access_model': 'open'}) if response.get('type') == 'error': condition(response, ERROR, 'precondition-not-met') response = result(client.iq('get-config', f"")) x = next(response.iter(f'{{{DATA}}}x')) x.set('type', 'submit') field = next(f for f in x if f.get('var') == 'pubsub#access_model') field.find(f'{{{DATA}}}value').text = 'open' result(client.iq('set-config', f"" + ET.tostring(x, encoding='unicode') + '', typ='set')) result(publish(client, node, 'current', payload, {'pubsub#access_model': 'open'})) check_items(client) # A missing item on an existing bundle node must also be benign to clients. condition(fetch(client, BUNDLES, '999'), STANZAS, 'item-not-found') # A PEP routing fix must keep another user's node protected. condition(client.iq('other-publish', f"", 'bob@localhost', 'set'), STANZAS, 'forbidden') def run(binary): with tempfile.TemporaryDirectory(prefix='xmpp-pep-clients-') 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 migrated in (False, True): with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) port = probe.getsockname()[1] data = root / ('migrated' if migrated else 'fresh') config = root / 'config.json' config.write_text(json.dumps({'global': {'data_path': str(data), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'pep']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}})) 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) if migrated: # Native-like metadata includes fields which previously failed whole-form resubmission. store = data / 'localhost' / 'pep' / 'alice.dat' store.parent.mkdir(parents=True, exist_ok=True) store.write_text('return { ' + ', '.join(f'["{node}"] = {{ config = {{ access_model = "presence", max_items = 1, persist_items = true, notification_type = "normal", itemreply = "publisher", payload_type = "{LEGACY}" }} }}' for node in (LEGACY + '.devicelist', OMEMO + ':devices')) + ' }\n') for restart in (False, True): with (root/'server.log').open('w+') as log: server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) client = None 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') features, identities = client.disco('own-pep', 'alice@localhost') assert ('pubsub', 'pep') in identities and PUBSUB + '#publish-options' in features, (features, identities) if restart: check_items(client) else: sequence(client) finally: if client is not None: client.sock.close() server.terminate() try: server.wait(timeout=5) except subprocess.TimeoutExpired: server.kill() server.wait() if sys.exc_info()[0] is not None: log.seek(0) print(log.read(), file=sys.stderr) print('PASS: TLS Monocles/Conversations PEP first-run, no-to publishing, owner config recovery, migrated metadata, device item selectors and restart persistence') if __name__ == '__main__': run(str(Path(sys.argv[1]).resolve()))