From a0debf0bd8a77f2acb984d2a45700979b9be355d Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Sun, 15 Feb 2026 08:19:38 +0200 Subject: [PATCH] feat: Add cross-signing bootstrap + canonicaljson dep Co-Authored-By: Claude Opus 4.6 --- bootstrap_cross_signing.py | 148 +++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 2 files changed, 149 insertions(+) create mode 100644 bootstrap_cross_signing.py diff --git a/bootstrap_cross_signing.py b/bootstrap_cross_signing.py new file mode 100644 index 0000000..0aa3d94 --- /dev/null +++ b/bootstrap_cross_signing.py @@ -0,0 +1,148 @@ +"""One-time script to bootstrap cross-signing keys for the bot.""" +import os +import json +import asyncio + +import olm.pk +import canonicaljson +import aiohttp +from nio import AsyncClient, AsyncClientConfig + +STORE = os.environ.get("CRYPTO_STORE_PATH", "/data/crypto_store") +CREDS_FILE = os.path.join(STORE, "credentials.json") +HS = os.environ["MATRIX_HOMESERVER"] +BOT_PASS = os.environ["MATRIX_BOT_PASSWORD"] + + +async def bootstrap(): + with open(CREDS_FILE) as f: + creds = json.load(f) + + config = AsyncClientConfig(encryption_enabled=True, store_sync_tokens=True) + client = AsyncClient(HS, creds["user_id"], store_path=STORE, config=config) + client.restore_login(creds["user_id"], creds["device_id"], creds["access_token"]) + client.load_store() + await client.sync(timeout=10000, full_state=True) + + user_id = creds["user_id"] + device_id = creds["device_id"] + token = creds["access_token"] + + # Generate cross-signing key pairs (PkSigning needs a 32-byte random seed) + import base64 + master_seed = os.urandom(32) + ss_seed = os.urandom(32) + us_seed = os.urandom(32) + master_key = olm.pk.PkSigning(master_seed) + self_signing_key = olm.pk.PkSigning(ss_seed) + user_signing_key = olm.pk.PkSigning(us_seed) + + master_pub = master_key.public_key + ss_pub = self_signing_key.public_key + us_pub = user_signing_key.public_key + + print(f"Master key: {master_pub}") + print(f"Self-signing key: {ss_pub}") + print(f"User-signing key: {us_pub}") + + def make_key(usage, pubkey): + return { + "user_id": user_id, + "usage": [usage], + "keys": {"ed25519:" + pubkey: pubkey}, + } + + master_obj = make_key("master", master_pub) + ss_obj = make_key("self_signing", ss_pub) + us_obj = make_key("user_signing", us_pub) + + # Sign sub-keys with master key + ss_canonical = canonicaljson.encode_canonical_json(ss_obj) + ss_sig = master_key.sign(ss_canonical) + ss_obj["signatures"] = {user_id: {"ed25519:" + master_pub: ss_sig}} + + us_canonical = canonicaljson.encode_canonical_json(us_obj) + us_sig = master_key.sign(us_canonical) + us_obj["signatures"] = {user_id: {"ed25519:" + master_pub: us_sig}} + + # Upload cross-signing keys with password auth + upload_body = { + "master_key": master_obj, + "self_signing_key": ss_obj, + "user_signing_key": us_obj, + "auth": { + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": user_id}, + "password": BOT_PASS, + }, + } + + headers = {"Authorization": "Bearer " + token} + + async with aiohttp.ClientSession() as session: + async with session.post( + HS + "/_matrix/client/v3/keys/device_signing/upload", + json=upload_body, + headers=headers, + ) as resp: + body = await resp.text() + print(f"Upload cross-signing keys: {resp.status} {body}") + + if resp.status != 200: + await client.close() + return + + # Sign our own device with self-signing key + # Fetch the device keys from the server to sign the exact object + async with session.post( + HS + "/_matrix/client/v3/keys/query", + json={"device_keys": {user_id: [device_id]}}, + headers=headers, + ) as qresp: + qbody = await qresp.json() + print(f"Keys query: {qresp.status}") + + device_obj = qbody["device_keys"][user_id][device_id] + # Remove existing signatures before signing + device_obj.pop("signatures", None) + device_obj.pop("unsigned", None) + dk_canonical = canonicaljson.encode_canonical_json(device_obj) + dk_sig = self_signing_key.sign(dk_canonical) + + # Add the cross-signing signature to the device object + device_obj["signatures"] = { + user_id: { + "ed25519:" + ss_pub: dk_sig, + } + } + + sig_upload = { + user_id: { + device_id: device_obj, + } + } + + async with session.post( + HS + "/_matrix/client/v3/keys/signatures/upload", + json=sig_upload, + headers=headers, + ) as resp2: + body2 = await resp2.text() + print(f"Upload device signature: {resp2.status} {body2}") + + # Save cross-signing seeds for persistence + xsign_file = os.path.join(STORE, "cross_signing_keys.json") + with open(xsign_file, "w") as f: + json.dump({ + "master_seed": base64.b64encode(master_seed).decode(), + "self_signing_seed": base64.b64encode(ss_seed).decode(), + "user_signing_seed": base64.b64encode(us_seed).decode(), + }, f) + print(f"Cross-signing seeds saved to {xsign_file}") + + await client.close() + print("Done! Bot device is now cross-signed.") + + +if __name__ == "__main__": + asyncio.run(bootstrap()) diff --git a/requirements.txt b/requirements.txt index 7194249..be0843d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ livekit-plugins-silero>=1.4,<2.0 livekit>=1.0,<2.0 livekit-api>=1.0,<2.0 matrix-nio[e2e]>=0.25,<1.0 +canonicaljson>=2.0,<3.0