Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions plugins/platforms/matrix/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,11 +594,21 @@ def _resolve_max_message_length(config) -> int:
MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH

# Store directory for E2EE keys and sync state.
# Uses get_hermes_home() so each profile gets its own Matrix store.
# Resolved per-instance (not at module scope) so each profile in a
# multiplexed gateway gets its own Matrix store instead of all sharing
# the default profile's crypto.db. The multiplexer imports this module
# once; a module-level constant would resolve to the root HERMES_HOME for
# every profile, so every bot's Olm identity collides in one store and
# inbound E2EE fails ("no session found"). Mirror the pairing-store fix
# (a6397c379) which moved the same module-level constant to per-profile
# resolution.
from hermes_constants import get_hermes_dir as _get_hermes_dir

_STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
_CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
# Back-compat alias for callers/tests that imported the legacy constant.
# The value is resolved lazily via MatrixAdapter._get_store_path(); keeping
# the name as None at module scope preserves the old import surface without
# pinning a single shared path for all profiles.
_CRYPTO_DB_PATH = None

# Grace period: ignore messages older than this many seconds before startup.
_STARTUP_GRACE_SECONDS = 5
Expand Down Expand Up @@ -1187,6 +1197,27 @@ class MatrixAdapter(BasePlatformAdapter):
max_message_length = DEFAULT_MAX_MESSAGE_LENGTH
_split_threshold = DEFAULT_MAX_MESSAGE_LENGTH - 100

def _get_store_path(self) -> Path:
"""Resolve this instance's crypto-store directory (per-profile).

Unlike the legacy module-level ``_STORE_DIR``/``_CRYPTO_DB_PATH``
(which the multiplex gateway resolved once to the root HERMES_HOME
for every profile), this resolves through the active profile's
HERMES_HOME at connect time. Under ``gateway.multiplex_profiles``
each profile's adapter is created and connected inside
``_profile_runtime_scope``, so ``get_hermes_dir`` — which honors the
context-local HERMES_HOME override — returns that profile's store
dir. Two profiles therefore never share a crypto.db; without this,
all multiplexed bots write their Olm identity to one shared store
and inbound E2EE fails with "no session found".
"""
store_dir = _get_hermes_dir("platforms/matrix/store", "matrix/store")
return store_dir / "crypto.db"

def _get_store_dir(self) -> Path:
"""Resolve this instance's crypto-store directory (for mkdir/pickle)."""
return _get_hermes_dir("platforms/matrix/store", "matrix/store")

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MATRIX)

Expand Down Expand Up @@ -1672,7 +1703,7 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:
"Matrix: server has different identity keys for device %s — "
"local crypto state is stale. Delete %s and restart.",
client.device_id,
_CRYPTO_DB_PATH,
str(self._get_store_path()),
)
return False

Expand Down Expand Up @@ -1729,7 +1760,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
return False

# Ensure store dir exists for E2EE key persistence.
_STORE_DIR.mkdir(parents=True, exist_ok=True)
self._get_store_dir().mkdir(parents=True, exist_ok=True)

# Create the HTTP API layer.
client_session = _create_matrix_session(self._proxy_url)
Expand Down Expand Up @@ -1886,7 +1917,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
from mautrix.crypto.store.asyncpg import PgCryptoStore
from mautrix.util.async_db import Database

_STORE_DIR.mkdir(parents=True, exist_ok=True)
self._get_store_dir().mkdir(parents=True, exist_ok=True)
except Exception as exc:
if self._e2ee_mode == "optional":
logger.warning(
Expand All @@ -1907,15 +1938,15 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
if self._encryption:
try:
# Remove legacy pickle file from pre-SQLite era.
legacy_pickle = _STORE_DIR / "crypto_store.pickle"
legacy_pickle = self._get_store_dir() / "crypto_store.pickle"
if legacy_pickle.exists():
logger.info(
"Matrix: removing legacy crypto_store.pickle (migrated to SQLite)"
)
legacy_pickle.unlink()

crypto_db = Database.create(
f"sqlite:///{_CRYPTO_DB_PATH}",
f"sqlite:///{self._get_store_path()}",
upgrade_table=PgCryptoStore.upgrade_table,
)
await crypto_db.start()
Expand Down Expand Up @@ -2043,7 +2074,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
client.crypto = olm
logger.info(
"Matrix: E2EE enabled (store: %s%s)",
str(_CRYPTO_DB_PATH),
str(self._get_store_path()),
f", device_id={client.device_id}" if client.device_id else "",
)
except Exception as exc:
Expand Down Expand Up @@ -2285,7 +2316,7 @@ def get_diagnostics(self) -> Dict[str, Any]:
"mode": self._e2ee_mode,
"enabled": bool(self._encryption),
"deps_available": _check_e2ee_deps(),
"crypto_store_path": str(_CRYPTO_DB_PATH),
"crypto_store_path": str(self._get_store_path()),
"recovery_key_configured": bool(
_scoped_recovery_key().strip()
),
Expand Down
93 changes: 93 additions & 0 deletions tests/gateway/test_matrix_crypto_store_per_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Regression test: Matrix crypto store must be resolved per-instance, not
at module scope, so multiplexed profiles never share one crypto.db.

Under ``gateway.multiplex_profiles`` a single gateway process imports
``plugins.platforms.matrix.adapter`` ONCE. The old module-level
``_STORE_DIR``/``_CRYPTO_DB_PATH`` resolved against the root HERMES_HOME
at import time, so every profile's adapter opened the SAME crypto.db — all
bots' Olm identities landed in one store and inbound E2EE failed with
"Error decrypting megolm event, no session found". The fix mirrors the
pairing-store migration (a6397c379): resolve the store path per instance
through the active profile's HERMES_HOME (``get_hermes_dir`` honors the
context-local override installed by ``_profile_runtime_scope``).

These tests exercise the resolver directly with ``set_hermes_home_override``,
the same contextvar the multiplexer uses, so no network or mautrix needed.
"""
from pathlib import Path

from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from plugins.platforms.matrix.adapter import MatrixAdapter
from gateway.config import PlatformConfig


def _make_adapter() -> MatrixAdapter:
return MatrixAdapter(
PlatformConfig(
enabled=True,
token="syt_test_token",
extra={
"homeserver": "https://matrix.example.org",
"user_id": "@bot:example.org",
},
)
)


def test_two_profiles_resolve_distinct_crypto_stores(tmp_path):
"""Two profile homes must yield different crypto.db paths."""
prof_a = tmp_path / "profiles" / "accountant"
prof_b = tmp_path / "profiles" / "engineering-lead"
prof_a.mkdir(parents=True)
prof_b.mkdir(parents=True)

adapter = _make_adapter()

token_a = set_hermes_home_override(str(prof_a))
try:
path_a = adapter._get_store_path()
finally:
reset_hermes_home_override(token_a)

token_b = set_hermes_home_override(str(prof_b))
try:
path_b = adapter._get_store_path()
finally:
reset_hermes_home_override(token_b)

assert path_a != path_b
assert path_a.name == "crypto.db"
assert path_b.name == "crypto.db"
# Each path lives under its own profile home — never a shared root.
assert str(path_a).replace("\\", "/").startswith(
str(prof_a).replace("\\", "/")
), f"store not profile-scoped: {path_a}"
assert str(path_b).replace("\\", "/").startswith(
str(prof_b).replace("\\", "/")
), f"store not profile-scoped: {path_b}"


def test_store_path_is_resolved_per_call_not_cached_at_module_scope(tmp_path):
"""Changing the active profile changes the resolved path — no module-level pin."""
prof_a = tmp_path / "profiles" / "a"
prof_b = tmp_path / "profiles" / "b"
prof_a.mkdir(parents=True)
prof_b.mkdir(parents=True)

adapter = _make_adapter()

token_a = set_hermes_home_override(str(prof_a))
try:
first = adapter._get_store_path()
finally:
reset_hermes_home_override(token_a)

token_b = set_hermes_home_override(str(prof_b))
try:
second = adapter._get_store_path()
finally:
reset_hermes_home_override(token_b)

assert first != second
assert first.parent.name == "store"
assert second.parent.name == "store"