From 9fd2f847414f26ed25a344ccf958e4084c2d33ad Mon Sep 17 00:00:00 2001 From: Kierre Date: Wed, 10 Sep 2025 20:05:38 -0400 Subject: [PATCH] Host our public key, and add proper ways to sign JSON + send requests. --- src/config-example.py | 10 ++-- src/globals.py | 107 ++++++++++++++++++++++++++++++++---------- src/main.py | 4 +- src/s2s.py | 68 +++++++++++++++++++++------ 4 files changed, 142 insertions(+), 47 deletions(-) diff --git a/src/config-example.py b/src/config-example.py index c63f221..203d9a3 100644 --- a/src/config-example.py +++ b/src/config-example.py @@ -19,7 +19,7 @@ room_dir_room = { "world_readable": False } ], - "total_room_count_estimate": 43502 + "total_room_count_estimate": 1 } # Where users should reach out for support. @@ -49,9 +49,7 @@ users_can_register = False # The funny number. the_funny_number = 1337 -# Your private key for Vona. +# Your private key for Vona, in the format of: +# ed25519 # -# To generate a private key: -# openssl genpkey -algorithm Ed25519 -out privkey.pem -# -ed25519_key_path = "/etc/vonakey.pem" +signing_key = "" diff --git a/src/globals.py b/src/globals.py index a5bbd93..c3b60bd 100644 --- a/src/globals.py +++ b/src/globals.py @@ -1,50 +1,107 @@ -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import serialization +import nacl.signing import hashlib import base64 +import config import json import re import os +vona_version = "1.2.4" + + def canonical_json(value): return json.dumps( value, ensure_ascii=False, - separators=(',',':'), - sort_keys=True + separators=(",", ":"), + sort_keys=True, ).encode("UTF-8") -''' -def encode_base64(data: bytes) -> str: - return base64.b64encode(data).decode('utf-8') -def sign_json(json_object, signing_key, signing_name): - signatures = json_object.pop("signatures", {}) - unsigned = json_object.pop("unsigned", None) +def sign_json(data): + parts = config.signing_key.split() + base64_key = parts[2] - signed = signing_key.sign(canonical_json(json_object)) - signature_base64 = encode_base64(signed) + while len(base64_key) % 4 != 0: + base64_key += "=" - key_id = "ed25519:VonaA" - signatures.setdefault(signing_name, {})[key_id] = signature_base64 + decoded_key = base64.b64decode(base64_key) + signing_key = nacl.signing.SigningKey(decoded_key) - json_object["signatures"] = signatures - if unsigned is not None: - json_object["unsigned"] = unsigned + signed_message = signing_key.sign(canonical_json(data)) - return json_object -''' + signature = signed_message.signature + + key_version = parts[1] + signature_base64 = base64.b64encode(signature).decode("utf-8").rstrip("=") + + signed_json = { + **data, + "signatures": { + config.server_name: { + f"ed25519:{key_version}": signature_base64, + }, + }, + } + + return signed_json -vona_version = '1.2.3' def make_event_id(): - return re.sub(r'[\/+=]', '_', base64.b64encode(os.urandom(32)).decode('utf-8'))[:44] + return re.sub(r"[\/+=]", "_", base64.b64encode(os.urandom(32)).decode("utf-8"))[:44] + def hash_event(input) -> str: - input.pop('signatures', None) - input.pop('unsigned', None) + input.pop("signatures", None) + input.pop("unsigned", None) sha256_hash = hashlib.sha256(canonical_json(input)).digest() base64_encoded = base64.b64encode(sha256_hash) - - return base64_encoded.decode().rstrip('=') + + return base64_encoded.decode().rstrip("=") + + +def pubkey() -> str: + private_key = config.signing_key.split()[2] + + while len(private_key) % 4 != 0: + private_key += "=" + + public_key = nacl.signing.SigningKey(base64.b64decode(private_key)).verify_key + + return ( + public_key.encode(encoder=nacl.encoding.Base64Encoder) + .decode("utf-8") + .rstrip("=") + ) + + +def make_auth_header(destination, method, path, content=None) -> str: + request_json = { + "method": method, + "uri": path, + "origin": config.server_name, + "destination": destination, + } + + if content is not None: + request_json["content"] = content + + signed_json = sign_json(request_json) + + authorization_headers = [] + + for key, sig in signed_json["signatures"][origin_name].items(): + authorization_headers.append( + bytes( + 'X-Matrix origin="%s",destination="%s",key="%s",sig="%s"' + % ( + config.server_name, + destination, + key, + sig, + ) + ) + ) + + return ("Authorization", authorization_headers[0]) diff --git a/src/main.py b/src/main.py index 7a32657..68a380e 100644 --- a/src/main.py +++ b/src/main.py @@ -26,7 +26,7 @@ async def root(): @app.route("/_matrix/static/") async def matrix_static(): - return f'Vona {globals.vona_version} is running

It works! Vona {globals.vona_version} is running

Your Vona server is listening on this port and is ready for messages.

To use this server you\'ll need a Matrix client.

Welcome to the Matrix universe :)


matrix.org

' + return f'Vona {globals.vona_version} is running

It works! Vona {globals.vona_version} is running

Your Vona server is listening on this port and is ready for messages.

To use this server you\'ll need a Matrix client.

Welcome to the Matrix universe :)


matrix.org

' # Error handlers @@ -44,7 +44,7 @@ async def internal_error(error): # Well-known endpoints for federation, -# clients, and support information +# clients, and support information. @app.route("/.well-known/matrix/server") async def server(): return jsonify({"m.server": f"{config.server_name}:443"}) diff --git a/src/s2s.py b/src/s2s.py index 5ff0a80..5c069d6 100644 --- a/src/s2s.py +++ b/src/s2s.py @@ -1,21 +1,20 @@ from flask import Flask, jsonify, Response, request, send_file, abort, Blueprint -from globals import vona_version, make_event_id, hash_event from config import * -import requests +import globals import json +import time import os server = Blueprint('matrix_server', __name__) def send_join(request, roomId): - # We may have to include signatures here - # as well, which will be a pain + # We may have to include signatures here. eventIds = [ - f"${make_event_id()}:{server_name}", - f"${make_event_id()}:{server_name}", - f"${make_event_id()}:{server_name}", - f"${make_event_id()}:{server_name}" + f"${globals.make_event_id()}:{server_name}", + f"${globals.make_event_id()}:{server_name}", + f"${globals.make_event_id()}:{server_name}", + f"${globals.make_event_id()}:{server_name}" ] events = { @@ -67,7 +66,7 @@ def send_join(request, roomId): } for event in events['state']: - event_hash = hash_event(event) + event_hash = globals.hash_event(event) if event['type'] != "m.room.create": event['prev_events'] = [eventIds[event['origin_server_ts'] - 1], {"sha256": event_hash}] @@ -75,6 +74,7 @@ def send_join(request, roomId): join_event['prev_events'] = [eventIds[3], {"sha256": events['state'][3]['prev_events'][1]['sha256']}] events['event'] = join_event + # debug print(json.dumps(events, indent='\t')) print(json.dumps(eventIds, indent='\t')) @@ -82,12 +82,20 @@ def send_join(request, roomId): @server.route('/_matrix/federation/v1/version') def version(): - return jsonify({"server": {"version": vona_version,"name": "Vona"}}) + return jsonify({"server": {"version": globals.vona_version,"name": "Vona"}}) @server.route('/_matrix/key/v2/server') def keys(): - # todo - return jsonify({}) + return jsonify(globals.sign_json({ + "old_verify_keys": {}, + "server_name": server_name, + "valid_until_ts": int(time.time() * 1000 + 604800000), + "verify_keys": { + f"ed25519:{signing_key.split()[1]}": { + "key": globals.pubkey() + } + } + })) @server.route('/_matrix/federation/v1/query/directory') def room_query(): @@ -116,6 +124,7 @@ def download_media(media_id): response = Response(response_body, content_type=f'multipart/mixed; boundary={boundary}') response.status_code = 200 + return response @server.route('/_matrix/federation/v1/media/thumbnail/') @@ -182,6 +191,7 @@ def user_profile(): return jsonify({"displayname":"Vona"}) else: return jsonify({"errcode": "M_NOT_FOUND","error": "The requested profile key does not exist."}), 404 + return jsonify({"avatar_url": f"mxc://{server_name}/cat","displayname": "Vona"}) @server.route('/_matrix/federation/v1/user/devices/') @@ -196,9 +206,39 @@ def user_devices(user): def user_keys(): return jsonify({"device_keys":{f"@vona:{server_name}":{}}}) + +# https://spec.matrix.org/v1.15/server-server-api/#inviting-to-a-room @server.route('/_matrix/federation/v2/invite//', methods=['PUT']) -def fed_invite_user(room, txnId): - return jsonify({"errcode":"M_INCOMPATIBLE_ROOM_VERSION","error": "Don't touch my users mofo","room_version": str(the_funny_number)}), 400 +def invite_user_v2(room, txnId): + # This endpoint is not allowed for room + # versions over v2 as per spec, so any + # invites received here can be discarded + # safely. + + return jsonify({ + "errcode": "M_INCOMPATIBLE_ROOM_VERSION", + "error": "Vona only supports room version 2.", + "room_version": str(the_funny_number) + }), 400 + +@server.route('/_matrix/federation/v1/invite//', methods=['PUT']) +def invite_user(room, txnId): + # TODO: Sign provided JSON and return it. + + data = request.data.decode('utf-8') + invite_data = json.loads(data) + + if "room_version" in invite_data: + if invite_data["room_version"] != "2": + return jsonify({ + "errcode": "M_INCOMPATIBLE_ROOM_VERSION", + "error": "Vona only supports room version 2.", + "room_version": invite_data["room_version"] + }) + + # Placeholder + abort(500) + @server.route('/_matrix/federation/v1/hierarchy/') def space_hierachy(roomId):