refactor(matrix): single re-upload path in device-key verification, synthetic thread-root policy as one predicate, merged E2EE setup try (phase label), recovery-key path helper (3023->3008)

This commit is contained in:
Teknium
2026-09-02 23:52:54 -07:00
parent 0c500d4a61
commit ebec8fa1bb
+103 -118
View File
@@ -494,15 +494,18 @@ def _extra_csv_set(config, key: str, env_name: str) -> Set[str]:
return _csv_set(raw)
def _recovery_key_output_path() -> Optional[Path]:
output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip()
return Path(output_file).expanduser() if output_file else None
def _write_matrix_recovery_key_output_file(recovery_key: str) -> Optional[Path]:
"""Write a generated recovery key to MATRIX_RECOVERY_KEY_OUTPUT_FILE (0600, never overwritten)."""
output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip()
if not output_file:
path = _recovery_key_output_path()
if path is None:
return None
path = Path(output_file).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
fd = os.open(path, flags, 0o600)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(recovery_key)
@@ -516,10 +519,9 @@ def _write_matrix_recovery_key_output_file(recovery_key: str) -> Optional[Path]:
def _get_matrix_recovery_key_output_target() -> tuple[Optional[Path], str]:
"""Return a usable one-time recovery-key output path, or a redacted reason."""
output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip()
if not output_file:
path = _recovery_key_output_path()
if path is None:
return None, "not_configured"
path = Path(output_file).expanduser()
if path.exists():
return None, "exists"
try:
@@ -913,11 +915,10 @@ class MatrixAdapter(BasePlatformAdapter):
for legacy_key in (f"{acct_id}:default", acct_id):
if legacy_key == pickle_key:
continue
legacy_store = PgCryptoStore(account_id=acct_id, pickle_key=legacy_key, db=crypto_db)
try:
account = await legacy_store.get_account()
account = await PgCryptoStore(account_id=acct_id, pickle_key=legacy_key, db=crypto_db).get_account()
except Exception:
continue
account = None
if account is None:
continue
# Sessions first, account last: the account is the commit marker (the fast path
@@ -977,36 +978,34 @@ class MatrixAdapter(BasePlatformAdapter):
logger.error("Matrix: cannot verify device keys on server: %s — refusing E2EE", exc, exc_info=True)
return False
local_ed25519 = olm.account.identity_keys.get("ed25519")
async def _reupload(error_fmt: str, *error_args) -> bool:
try:
await olm.share_keys()
except Exception as exc:
logger.error(error_fmt, *error_args, exc, exc_info=True)
return False
return await self._reverify_keys_after_upload(client, local_ed25519)
if not our_keys:
logger.warning("Matrix: device keys missing from server — re-uploading")
olm.account.shared = False
try:
await olm.share_keys()
except Exception as exc:
logger.error("Matrix: failed to re-upload device keys: %s", exc, exc_info=True)
return False
return await self._reverify_keys_after_upload(client, local_ed25519)
if self._extract_server_ed25519(our_keys) != local_ed25519:
if olm.account.shared:
logger.error(
"Matrix: server has different identity keys for device %s — local crypto state is "
"stale. Delete %s and restart.", client.device_id, str(self._crypto_db_path))
return False
logger.warning("Matrix: server has stale keys for device %s — attempting re-upload", client.device_id)
with suppress(Exception):
await client.api.request(
client.api.Method.DELETE if hasattr(client.api, "Method") else "DELETE",
f"/_matrix/client/v3/devices/{client.device_id}")
logger.info("Matrix: deleted stale device %s from server", client.device_id)
try:
await olm.share_keys()
except Exception as exc:
logger.error(
"Matrix: cannot upload device keys for %s: %s. Try generating a new access token "
"to get a fresh device.", client.device_id, exc, exc_info=True)
return False
return await self._reverify_keys_after_upload(client, local_ed25519)
return True
return await _reupload("Matrix: failed to re-upload device keys: %s")
if self._extract_server_ed25519(our_keys) == local_ed25519:
return True
if olm.account.shared:
logger.error(
"Matrix: server has different identity keys for device %s — local crypto state is "
"stale. Delete %s and restart.", client.device_id, str(self._crypto_db_path))
return False
logger.warning("Matrix: server has stale keys for device %s — attempting re-upload", client.device_id)
with suppress(Exception):
await client.api.request(
client.api.Method.DELETE if hasattr(client.api, "Method") else "DELETE",
f"/_matrix/client/v3/devices/{client.device_id}")
logger.info("Matrix: deleted stale device %s from server", client.device_id)
return await _reupload(
"Matrix: cannot upload device keys for %s: %s. Try generating a new access token to get a fresh device.",
client.device_id)
@staticmethod
async def _abort_connect(api: Any, crypto_db: Any = None) -> bool:
@@ -1093,63 +1092,60 @@ class MatrixAdapter(BasePlatformAdapter):
"Matrix: E2EE is required but dependencies are missing. %s. Refusing to connect — "
"encrypted rooms would silently fail.", _E2EE_INSTALL_HINT)
return await self._abort_connect(api)
if self._encryption:
if not self._encryption:
return True
phase = "import"
try:
from mautrix.crypto import OlmMachine
from mautrix.crypto.store.asyncpg import PgCryptoStore
from mautrix.util.async_db import Database
self._store_dir.mkdir(parents=True, exist_ok=True)
phase = "create"
if (self._store_dir / "crypto_store.pickle").exists(): # pre-SQLite era
logger.info("Matrix: removing legacy crypto_store.pickle (migrated to SQLite)")
(self._store_dir / "crypto_store.pickle").unlink()
crypto_db = Database.create(
f"sqlite:///{self._crypto_db_path}", upgrade_table=PgCryptoStore.upgrade_table)
await crypto_db.start()
self._crypto_db = crypto_db
_acct_id = self._user_id or "hermes"
# Key on the RESOLVED client.device_id (token's real device), not the configured
# one, or the Olm account is stored under a key that can never be looked up.
_pickle_key = f"{_acct_id}:{client.device_id or self._device_id or 'default'}"
crypto_store = PgCryptoStore(account_id=_acct_id, pickle_key=_pickle_key, db=crypto_db)
await crypto_store.open()
_store_was_reset = False
if client.device_id:
_store_was_reset = await self._reset_crypto_store_if_device_changed(crypto_store, client.device_id)
await crypto_store.put_device_id(client.device_id)
# A just-deleted store has no account to migrate.
if not _store_was_reset and not await self._migrate_legacy_crypto_pickle(
crypto_store, crypto_db, _acct_id, _pickle_key):
logger.warning("Matrix: crypto pickle migration failed — E2EE may not work correctly")
crypto_state = _CryptoStateStore(state_store, self._joined_rooms, client)
olm = OlmMachine(client, crypto_store, crypto_state)
olm.share_keys_min_trust = TrustState.UNVERIFIED
olm.send_keys_min_trust = TrustState.UNVERIFIED
await olm.load()
if not await self._verify_device_keys_on_server(client, olm):
return await self._abort_connect(api, crypto_db)
try:
from mautrix.crypto import OlmMachine
from mautrix.crypto.store.asyncpg import PgCryptoStore
from mautrix.util.async_db import Database
self._store_dir.mkdir(parents=True, exist_ok=True)
await olm.share_keys()
except Exception as exc:
if not await self._e2ee_setup_failed("import", exc, api):
return False
if self._encryption:
try:
if (self._store_dir / "crypto_store.pickle").exists(): # pre-SQLite era
logger.info("Matrix: removing legacy crypto_store.pickle (migrated to SQLite)")
(self._store_dir / "crypto_store.pickle").unlink()
crypto_db = Database.create(
f"sqlite:///{self._crypto_db_path}", upgrade_table=PgCryptoStore.upgrade_table)
await crypto_db.start()
self._crypto_db = crypto_db
_acct_id = self._user_id or "hermes"
# Key on the RESOLVED client.device_id (token's real device), not the configured
# one, or the Olm account is stored under a key that can never be looked up.
_pickle_key = f"{_acct_id}:{client.device_id or self._device_id or 'default'}"
crypto_store = PgCryptoStore(account_id=_acct_id, pickle_key=_pickle_key, db=crypto_db)
await crypto_store.open()
_store_was_reset = False
if client.device_id:
_store_was_reset = await self._reset_crypto_store_if_device_changed(crypto_store, client.device_id)
await crypto_store.put_device_id(client.device_id)
# A just-deleted store has no account to migrate.
if not _store_was_reset and not await self._migrate_legacy_crypto_pickle(
crypto_store, crypto_db, _acct_id, _pickle_key):
logger.warning("Matrix: crypto pickle migration failed — E2EE may not work correctly")
crypto_state = _CryptoStateStore(state_store, self._joined_rooms, client)
olm = OlmMachine(client, crypto_store, crypto_state)
olm.share_keys_min_trust = TrustState.UNVERIFIED
olm.send_keys_min_trust = TrustState.UNVERIFIED
await olm.load()
if not await self._verify_device_keys_on_server(client, olm):
if "already exists" in str(exc):
logger.error(
"Matrix: device %s has stale one-time keys on the server signed with a "
"previous identity key. Delete the device from the homeserver and restart, "
"or generate a new access token to get a fresh device ID.", client.device_id)
return await self._abort_connect(api, crypto_db)
try:
await olm.share_keys()
except Exception as exc:
if "already exists" in str(exc):
logger.error(
"Matrix: device %s has stale one-time keys on the server signed with a "
"previous identity key. Delete the device from the homeserver and restart, "
"or generate a new access token to get a fresh device ID.", client.device_id)
return await self._abort_connect(api, crypto_db)
logger.warning("Matrix: share_keys() warning during startup: %s", exc)
await self._verify_or_bootstrap_cross_signing(olm, client)
client.crypto = olm
logger.info(
"Matrix: E2EE enabled (store: %s%s)", str(self._crypto_db_path),
f", device_id={client.device_id}" if client.device_id else "")
except Exception as exc:
if not await self._e2ee_setup_failed("create", exc, api):
return False
logger.warning("Matrix: share_keys() warning during startup: %s", exc)
await self._verify_or_bootstrap_cross_signing(olm, client)
client.crypto = olm
logger.info(
"Matrix: E2EE enabled (store: %s%s)", str(self._crypto_db_path),
f", device_id={client.device_id}" if client.device_id else "")
except Exception as exc:
return await self._e2ee_setup_failed(phase, exc, api)
return True
async def _e2ee_setup_failed(self, what: str, exc: Exception, api: Any) -> bool:
@@ -1422,14 +1418,6 @@ class MatrixAdapter(BasePlatformAdapter):
raise ValueError("external media is not an image")
return b"".join(parts), content_type
fname = url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
def _safe_redirect_target(current_url: str, location: str) -> str:
"""Re-validate EVERY redirect hop: a public URL can 302 toward loopback/metadata
endpoints, and checking only the final URL is too late (the hop already connected)."""
next_url = urljoin(current_url, location)
if not is_safe_url(next_url):
raise ValueError("blocked unsafe redirect URL")
return next_url
try:
import aiohttp as _aiohttp
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
@@ -1442,7 +1430,11 @@ class MatrixAdapter(BasePlatformAdapter):
location = resp.headers.get("Location")
if not location:
raise ValueError("redirect missing Location")
fetch_url = _safe_redirect_target(fetch_url, location)
# Re-validate EVERY hop: a public URL can 302 toward loopback/metadata endpoints,
# and checking only the final URL is too late (the hop already connected).
fetch_url = urljoin(fetch_url, location)
if not is_safe_url(fetch_url):
raise ValueError("blocked unsafe redirect URL")
continue
resp.raise_for_status()
data, ct = await _read_capped(
@@ -1881,14 +1873,11 @@ class MatrixAdapter(BasePlatformAdapter):
content = getattr(event, "content", None)
if content is None:
return
if hasattr(content, "msgtype"):
msgtype = str(content.msgtype)
else:
msgtype = content.get("msgtype", "") if isinstance(content, dict) else ""
if isinstance(content, dict):
source_content = content
source_content, msgtype = content, content.get("msgtype", "")
else:
source_content = content.serialize() if hasattr(content, "serialize") else {}
msgtype = str(content.msgtype) if hasattr(content, "msgtype") else ""
relates_to = source_content.get("m.relates_to", {})
if relates_to.get("rel_type") == "m.replace": # skip edits
return
@@ -1934,29 +1923,25 @@ class MatrixAdapter(BasePlatformAdapter):
"Matrix: ignoring message %s in thread %s — no @mention (thread_require_mention=true)",
event_id, thread_id)
return None
if is_dm and not thread_id and self._dm_mention_threads and is_mentioned:
thread_id = event_id
self._threads.mark(thread_id)
if is_mentioned and self._require_mention:
body = self._strip_mention(body)
# Real thread roots are preserved above; synthetic roots follow session-scope policy.
# Real thread roots are preserved above; synthetic roots (this event) follow policy: DM
# @mention threads / DM auto-thread, or room auto-thread unless session_scope pins the room.
if not thread_id:
if is_dm:
if self._dm_auto_thread:
thread_id = event_id
self._threads.mark(thread_id)
elif self._matrix_session_scope == "room":
thread_id = None
elif self._matrix_session_scope == "thread" or self._auto_thread:
synthetic = (self._dm_mention_threads and is_mentioned) or self._dm_auto_thread
else:
synthetic = self._matrix_session_scope == "thread" or (
self._matrix_session_scope != "room" and self._auto_thread)
if synthetic:
thread_id = event_id
self._threads.mark(thread_id)
display_name = await self._get_display_name(room_id, sender)
source = self.build_source(
chat_id=room_id, chat_name=identity.display_name, chat_type=chat_type, user_id=sender,
user_name=display_name, thread_id=thread_id, chat_topic=identity.room_topic,
guild_id=identity.server_name, parent_chat_id=room_id if thread_id else None, message_id=event_id)
if thread_id:
self._threads.mark(thread_id)
self._threads.mark(thread_id) # covers real roots and synthetic ones alike
self._background_read_receipt(room_id, event_id)
return body, is_dm, chat_type, thread_id, display_name, source