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:
170
bot.py
170
bot.py
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user