diff --git a/bot.py b/bot.py index 237542a..564f468 100644 --- a/bot.py +++ b/bot.py @@ -15,6 +15,8 @@ import fitz # pymupdf import httpx from openai import AsyncOpenAI from olm import sas as olm_sas +import olm.pk +import canonicaljson from rag_key_manager import RAGKeyManager from nio import ( @@ -1093,6 +1095,9 @@ class Bot: if self.client.should_upload_keys: await self.client.keys_upload() + # Bootstrap cross-signing if not already done + await self._ensure_cross_signing() + self.lkapi = api.LiveKitAPI(LK_URL, LK_KEY, LK_SECRET) self.client.add_event_callback(self.on_invite, InviteMemberEvent) self.client.add_event_callback(self.on_megolm, MegolmEvent) @@ -1111,6 +1116,133 @@ class Bot: await self.client.sync_forever(timeout=30000, full_state=True) + async def _ensure_cross_signing(self): + """Ensure bot device is cross-signed so Element clients don't show authenticity warnings.""" + xsign_file = os.path.join(STORE_PATH, "cross_signing_keys.json") + + with open(CREDS_FILE) as f: + creds = json.load(f) + + user_id = creds["user_id"] + device_id = creds["device_id"] + token = creds["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Check if device already has a cross-signing signature on the server + try: + async with httpx.AsyncClient(timeout=15.0) as hc: + resp = await hc.post( + f"{HOMESERVER}/_matrix/client/v3/keys/query", + json={"device_keys": {user_id: [device_id]}}, + headers=headers, + ) + if resp.status_code == 200: + device_keys = resp.json().get("device_keys", {}).get(user_id, {}).get(device_id, {}) + sigs = device_keys.get("signatures", {}).get(user_id, {}) + device_key_id = f"ed25519:{device_id}" + has_cross_sig = any(k != device_key_id for k in sigs) + if has_cross_sig: + logger.info("Device %s already cross-signed, skipping bootstrap", device_id) + return + except Exception as e: + logger.warning("Cross-signing check failed: %s", e) + + # Load existing seeds or generate new ones + if os.path.exists(xsign_file): + with open(xsign_file) as f: + seeds = json.load(f) + master_seed = base64.b64decode(seeds["master_seed"]) + ss_seed = base64.b64decode(seeds["self_signing_seed"]) + us_seed = base64.b64decode(seeds["user_signing_seed"]) + logger.info("Loaded existing cross-signing seeds, re-signing device") + else: + master_seed = os.urandom(32) + ss_seed = os.urandom(32) + us_seed = os.urandom(32) + logger.info("Generating new cross-signing keys") + + master_key = olm.pk.PkSigning(master_seed) + self_signing_key = olm.pk.PkSigning(ss_seed) + user_signing_key = olm.pk.PkSigning(us_seed) + + def make_key(usage, pubkey): + return { + "user_id": user_id, + "usage": [usage], + "keys": {"ed25519:" + pubkey: pubkey}, + } + + master_obj = make_key("master", master_key.public_key) + ss_obj = make_key("self_signing", self_signing_key.public_key) + us_obj = make_key("user_signing", user_signing_key.public_key) + + # Sign sub-keys with master key + ss_canonical = canonicaljson.encode_canonical_json(ss_obj) + ss_obj["signatures"] = {user_id: {"ed25519:" + master_key.public_key: master_key.sign(ss_canonical)}} + + us_canonical = canonicaljson.encode_canonical_json(us_obj) + us_obj["signatures"] = {user_id: {"ed25519:" + master_key.public_key: master_key.sign(us_canonical)}} + + try: + async with httpx.AsyncClient(timeout=15.0) as hc: + # Upload cross-signing keys with password auth + resp = await hc.post( + f"{HOMESERVER}/_matrix/client/v3/keys/device_signing/upload", + json={ + "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=headers, + ) + if resp.status_code != 200: + logger.error("Cross-signing upload failed: %d %s", resp.status_code, resp.text) + return + logger.info("Cross-signing keys uploaded") + + # Fetch device keys to sign + qresp = await hc.post( + f"{HOMESERVER}/_matrix/client/v3/keys/query", + json={"device_keys": {user_id: [device_id]}}, + headers=headers, + ) + device_obj = qresp.json()["device_keys"][user_id][device_id] + device_obj.pop("signatures", None) + device_obj.pop("unsigned", None) + + # Sign device with self-signing key + dk_canonical = canonicaljson.encode_canonical_json(device_obj) + dk_sig = self_signing_key.sign(dk_canonical) + device_obj["signatures"] = {user_id: {"ed25519:" + self_signing_key.public_key: dk_sig}} + + resp2 = await hc.post( + f"{HOMESERVER}/_matrix/client/v3/keys/signatures/upload", + json={user_id: {device_id: device_obj}}, + headers=headers, + ) + if resp2.status_code != 200: + logger.error("Device signature upload failed: %d %s", resp2.status_code, resp2.text) + return + logger.info("Device %s cross-signed successfully", device_id) + except Exception as e: + logger.error("Cross-signing bootstrap failed: %s", e, exc_info=True) + return + + # Save seeds if new + if not os.path.exists(xsign_file): + 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) + logger.info("Cross-signing seeds saved to %s", xsign_file) + async def _inject_rag_key(self): """Load document encryption key from Matrix and inject into RAG service.""" try: