feat(CF-2502): proper E2E encryption with cross-signing and device lifecycle

Replace insecure auto-trust-all-devices with cross-signed-only trust policy.
Extract cross-signing manager into reusable module with vault backup/recovery.
Add device cleanup script and automatic old device pruning on startup.

- device_trust.py: CrossSignedOnlyPolicy (only trust cross-signed devices)
- cross_signing.py: Extracted from bot.py, adds vault seed backup + recovery
- scripts/matrix_device_cleanup.py: Synapse Admin API bulk device cleanup CLI
- bot.py: Use new modules, add _cleanup_own_devices() on startup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-23 19:05:48 +02:00
parent bfc717372c
commit 7fd3aae176
4 changed files with 609 additions and 114 deletions

170
bot.py
View File

@@ -45,6 +45,8 @@ from livekit import api, rtc
from voice import VoiceSession
from article_summary import ArticleSummaryHandler
from cron import CronScheduler
from device_trust import CrossSignedOnlyPolicy
from cross_signing import CrossSigningManager
BOT_DEVICE_ID = "AIBOT"
CALL_MEMBER_TYPE = "org.matrix.msc3401.call.member"
@@ -1203,6 +1205,11 @@ class Bot:
self._loaded_rooms: set[str] = set() # rooms where we've loaded state
self._sync_token_received = False
self._verifications: dict[str, dict] = {} # txn_id -> verification state
self.trust_policy = CrossSignedOnlyPolicy()
self.cross_signing = CrossSigningManager(
HOMESERVER, STORE_PATH, BOT_PASS,
vault_key=f"matrix.{BOT_USER.split(':')[0].lstrip('@')}.cross_signing_seeds",
)
self._room_document_context: dict[str, list[dict]] = {} # room_id -> [{type, filename, text, timestamp}, ...]
# Article summary handler (Blinkist-style audio summaries)
if self.llm and ELEVENLABS_API_KEY:
@@ -1298,7 +1305,14 @@ class Bot:
await self.client.keys_upload()
# Bootstrap cross-signing if not already done
await self._ensure_cross_signing()
with open(CREDS_FILE) as f:
creds = json.load(f)
await self.cross_signing.ensure_cross_signing(
creds["user_id"], creds["device_id"], creds["access_token"],
)
# Clean up own old devices (keep max 3, older than 30 days)
await self._cleanup_own_devices(creds["device_id"], creds["access_token"])
self.lkapi = api.LiveKitAPI(LK_URL, LK_KEY, LK_SECRET)
self.client.add_event_callback(self.on_invite, InviteMemberEvent)
@@ -1335,132 +1349,57 @@ 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
async def _cleanup_own_devices(self, current_device_id: str, access_token: str, keep: int = 3, max_age_days: int = 30):
"""Remove own old devices via client API (no admin required)."""
headers = {"Authorization": f"Bearer {access_token}"}
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)
resp = await hc.get(f"{HOMESERVER}/_matrix/client/v3/devices", headers=headers)
if resp.status_code != 200:
logger.warning("Device list failed: %d", resp.status_code)
return
# 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")
devices = resp.json().get("devices", [])
if len(devices) <= keep:
return
master_key = olm.pk.PkSigning(master_seed)
self_signing_key = olm.pk.PkSigning(ss_seed)
user_signing_key = olm.pk.PkSigning(us_seed)
# Sort by last_seen_ts descending
devices.sort(key=lambda d: d.get("last_seen_ts") or 0, reverse=True)
def make_key(usage, pubkey):
return {
"user_id": user_id,
"usage": [usage],
"keys": {"ed25519:" + pubkey: pubkey},
}
now_ms = time.time() * 1000
to_delete = []
for i, dev in enumerate(devices):
if dev["device_id"] == current_device_id:
continue
if i < keep:
continue
last_seen = dev.get("last_seen_ts") or 0
if last_seen > 0 and (now_ms - last_seen) < max_age_days * 86400 * 1000:
continue
to_delete.append(dev["device_id"])
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)
if not to_delete:
return
# 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",
# Bulk delete own devices (requires password re-auth)
del_resp = await hc.post(
f"{HOMESERVER}/_matrix/client/v3/delete_devices",
json={
"master_key": master_obj,
"self_signing_key": ss_obj,
"user_signing_key": us_obj,
"devices": to_delete,
"auth": {
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": user_id},
"identifier": {"type": "m.id.user", "user": BOT_USER},
"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)
if del_resp.status_code == 200:
logger.info("Cleaned up %d old devices (kept %d)", len(to_delete), keep)
else:
logger.warning("Device cleanup failed: %d %s", del_resp.status_code, del_resp.text)
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)
logger.warning("Device cleanup error (non-fatal): %s", e)
async def _inject_rag_key(self):
"""Load document encryption key from Matrix and inject into RAG service."""
@@ -1502,7 +1441,7 @@ class Bot:
await self.client.join(room.room_id)
async def on_sync(self, response: SyncResponse):
"""After each sync, trust all devices in our rooms."""
"""After each sync, trust cross-signed devices only."""
if not self._sync_token_received:
self._sync_token_received = True
logger.info("Initial sync complete, text handler active")
@@ -1511,8 +1450,11 @@ class Bot:
for user_id in list(self.client.device_store.users):
for device in self.client.device_store.active_user_devices(user_id):
if not device.verified:
self.client.verify_device(device)
logger.info("Auto-trusted device %s of %s", device.device_id, user_id)
if self.trust_policy.should_trust(user_id, device):
self.client.verify_device(device)
logger.info("Cross-sign-verified device %s of %s", device.device_id, user_id)
else:
logger.debug("Skipped unverified device %s of %s (no cross-signing sig)", device.device_id, user_id)
async def on_reaction(self, room, event: ReactionEvent):
"""Handle reaction events for pipeline approval flow."""