#!/usr/bin/env python3 """Real STARTTLS regression for private PEP notification diagnostics. Usage: python3 tests/pep_notifications_network.py target/release/rust-xmpp Exercises generated notifications, payload suppression and matching IQ results. The fixtures demonstrate diagnostic coverage, not a production root cause. """ import json from pathlib import Path import socket import subprocess import sys import tempfile import time from _wire_client import Client PUBSUB = 'http://jabber.org/protocol/pubsub' EVENT = PUBSUB + '#event' OWNER = PUBSUB + '#owner' LEGACY = 'eu.siacs.conversations.axolotl' OMEMO2 = 'urn:xmpp:omemo:2' PRIVATE_ITEM = 'private-diagnostic-item-607b' PRIVATE_KEY = 'PRIVATE-KEY-MATERIAL-952a' VALID_ID = '1937264819' BAD_ID = 'private-invalid-id-65d2' def require(condition, reason): if not condition: raise AssertionError(reason) def fields(record): return {f['key']: f['value'] for f in record['fields']} def records(path): return [json.loads(line) for line in path.read_text().splitlines()] def wait_event(path, start, expected): deadline = time.monotonic() + 5 while time.monotonic() < deadline: events = [r for r in records(path)[start:] if r.get('event') == 'notification-generated'] for event in events: actual = fields(event) if all(actual.get(k) == v for k, v in expected.items()): require(event.get('module') == 'pep', 'wrong notification log module') require(event.get('connection') is None, 'notification log acquired an account context') return time.sleep(.02) raise AssertionError('required private notification diagnostic was not generated') def iq(client, identifier, payload, typ='set'): client.send(f"{payload}") return client.iq(identifier) def publish(client, node, payload, identifier): return iq(client, identifier, f"{payload}") def notification(client, node): # Client.take preserves unrelated IQs/messages and handles either wire order. message = client.take(lambda e: e.tag == '{jabber:client}message' and e.find(f'{{{EVENT}}}event/{{{EVENT}}}items') is not None and e.find(f'{{{EVENT}}}event/{{{EVENT}}}items').get('node') == node) require(message.get('from') == 'alice@localhost', 'notification sender must be account bare JID') wrapper = message.find(f'{{{EVENT}}}event/{{{EVENT}}}items') items = wrapper.findall(f'{{{EVENT}}}item') require(len(items) == 1, 'expected exactly one wire notification item') return items[0] def run(binary): with tempfile.TemporaryDirectory(prefix='xmpp-pep-notifications-') as tmp: root = Path(tmp) cert = root / 'cert.pem' subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root / 'key.pem'), '-out', str(cert), '-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] log_path = root / 'structured.jsonl' config = root / 'config.json' config.write_text(json.dumps({'global': { 'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'pep'], 'log_format': 'json', 'log': {'debug': str(log_path)}}, 'hosts': {'localhost': {'options': { 'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root / 'key.pem'), 'certificate': str(cert)}}}}})) subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) with (root / 'process.log').open('w') as output: process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=output, stderr=output) client = None try: for _ in range(100): require(process.poll() is None, 'fixture server stopped') try: with socket.create_connection(('127.0.0.1', port), timeout=.1): break except OSError: time.sleep(.05) else: raise AssertionError('fixture server did not listen') client = Client(port, cert, 'alice', 'secret', resource='notification-test') for index, (node, namespace, element, protocol) in enumerate([ (LEGACY + '.devicelist', LEGACY, 'list', 'legacy'), (OMEMO2 + ':devices', OMEMO2, 'devices', 'omemo2')]): payload = f"<{element} xmlns='{namespace}'>{PRIVATE_KEY}" publish(client, node, payload, f'initial-{index}') start = len(records(log_path)) iq(client, f'subscribe-{index}', f"") item = notification(client, node) require(item.find(f'{{{namespace}}}{element}') is not None, 'replayed wire notification lost the published device payload') wait_event(log_path, start, {'node_kind': 'omemo-devices', 'device_protocol': protocol, 'valid_device_count': '1', 'invalid_device_count': '1', 'content_present': 'true', 'items_count': '1'}) # Successful IQ retrieval and payloadless notifications can coexist. form = "http://jabber.org/protocol/pubsub#node_configfalse" iq(client, f'configure-{index}', f"{form}") start = len(records(log_path)) publish(client, node, payload, f'payloadless-{index}') require(len(notification(client, node)) == 0, 'deliver_payloads=false did not suppress wire payload') wait_event(log_path, start, {'node_kind': 'omemo-devices', 'device_protocol': 'other', 'valid_device_count': '0', 'invalid_device_count': '0', 'content_present': 'false', 'items_count': '1'}) response = iq(client, f'fetch-{index}', f"", typ='get') require(response.find(f'{{{PUBSUB}}}pubsub/{{{PUBSUB}}}items/{{{PUBSUB}}}item/{{{namespace}}}{element}') is not None, 'payload suppression unexpectedly removed stored IQ payload') diagnostics = [r for r in records(log_path) if r.get('event') in ('pubsub-iq-result', 'notification-generated')] serialized = json.dumps(diagnostics) for secret in (PRIVATE_ITEM, PRIVATE_KEY, VALID_ID, BAD_ID): require(secret not in serialized, 'private fixture value leaked into PEP diagnostics') iq_rows = [fields(r) for r in diagnostics if r['event'] == 'pubsub-iq-result'] for protocol in ('legacy', 'omemo2'): require(any(f.get('action') == 'items' and f.get('device_protocol') == protocol and f.get('valid_device_count') == '1' and f.get('invalid_device_count') == '1' for f in iq_rows), 'successful IQ device parser diagnostic missing') finally: if client is not None: client.close() process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait() print('PASS: STARTTLS PEP replay/live diagnostics, legacy/OMEMO2, payloadless notifications with intact IQ payloads, and private values excluded') if __name__ == '__main__': run(str(Path(sys.argv[1]).resolve()))