#!/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}'>{element}>"
# 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()))