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
30 changes: 28 additions & 2 deletions plugins/platforms/matrix/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
from pathlib import Path
from typing import Any, Dict, Optional, Set

from agent.secret_scope import UnscopedSecretError, get_secret

try:
from mautrix.types import (
ContentURI,
Expand Down Expand Up @@ -644,6 +646,24 @@ def _handle_generated_matrix_recovery_key(mxid: str, recovery_key: str) -> None:
)


def _scoped_recovery_key() -> str:
"""Resolve MATRIX_RECOVERY_KEY honoring the active profile's secret scope.

Under ``gateway.multiplex_profiles`` the secret scope holds the secondary
profile's credentials, while ``os.environ`` may carry the default profile's
key — so a bare ``os.getenv`` resolves the wrong key and E2EE verification
fails with "Key MAC does not match" (#69090). We read through
:func:`get_secret`, which is scope-aware. An *unscoped* read under multiplex
(e.g. the default-profile startup loop) raises ``UnscopedSecretError``; in
that context ``os.environ`` is that profile's own value, so we fall back to
it — mirroring the established Slack app-token pattern (#59739).
"""
try:
return (get_secret("MATRIX_RECOVERY_KEY") or "").strip()
except UnscopedSecretError:
return os.getenv("MATRIX_RECOVERY_KEY", "").strip()


def _sanitize_matrix_html(html: str) -> str:
sanitizer = _MatrixHtmlSanitizer()
try:
Expand Down Expand Up @@ -1400,7 +1420,11 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
return False
logger.warning("Matrix: share_keys() warning during startup: %s", exc)

recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
# Honor the active profile's secret scope so a secondary
# profile under gateway.multiplex_profiles resolves its own
# recovery key instead of the default profile's (which fails
# E2EE verification with "Key MAC does not match", #69090).
recovery_key = _scoped_recovery_key()
if recovery_key:
try:
await olm.verify_with_recovery_key(recovery_key)
Expand Down Expand Up @@ -1698,7 +1722,9 @@ def get_diagnostics(self) -> Dict[str, Any]:
"enabled": bool(self._encryption),
"deps_available": _check_e2ee_deps(),
"crypto_store_path": str(_CRYPTO_DB_PATH),
"recovery_key_configured": bool(os.getenv("MATRIX_RECOVERY_KEY", "").strip()),
"recovery_key_configured": bool(
_scoped_recovery_key().strip()
),
},
"policy": {
"allowed_user_count": len(self._allowed_user_ids),
Expand Down
79 changes: 79 additions & 0 deletions tests/gateway/test_matrix_recovery_key_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Regression test for #69090: MATRIX_RECOVERY_KEY must honor the active
profile's secret scope under ``gateway.multiplex_profiles`` so that a
secondary profile resolves its own recovery key (not the default profile's),
otherwise E2EE cross-signing verification fails with "Key MAC does not match".

The fix routes the recovery-key read through ``_scoped_recovery_key()``,
which uses :func:`agent.secret_scope.get_secret` (scope-aware) and only falls
back to ``os.getenv`` for an *unscoped* read under multiplex — mirroring the
established Slack app-token pattern (#59739).
"""
import pytest

from agent import secret_scope as ss
from plugins.platforms.matrix.adapter import _scoped_recovery_key


@pytest.fixture(autouse=True)
def _reset_multiplex():
"""Ensure each test starts and ends with multiplexing off (it's a global)."""
ss.set_multiplex_active(False)
yield
ss.set_multiplex_active(False)


class TestScopedRecoveryKey:
def test_multiplex_inactive_reads_environ(self, monkeypatch):
"""Default deployment: get_secret transparently reads os.environ."""
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "default-profile-key")
assert _scoped_recovery_key() == "default-profile-key"

def test_multiplex_active_scoped_uses_scope_not_environ(self, monkeypatch):
"""Secondary profile under multiplex must resolve its own key.

This is the core regression: ``os.getenv`` would have returned the
default profile's key (from os.environ), failing verification.
"""
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "default-profile-key")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"MATRIX_RECOVERY_KEY": "secondary-profile-key"})
try:
assert _scoped_recovery_key() == "secondary-profile-key"
finally:
ss.reset_secret_scope(token)

def test_multiplex_active_unscoped_falls_back_to_environ(self, monkeypatch):
"""Default-profile startup loop under multiplex: unscoped read is fine.

An unscoped read raises ``UnscopedSecretError``; in that context
os.environ holds that profile's own value, so we fall back to it rather
than crashing startup. This matches the Slack adapter's behavior.
"""
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "default-profile-key")
ss.set_multiplex_active(True)
# No secret scope installed -> get_secret raises UnscopedSecretError.
assert _scoped_recovery_key() == "default-profile-key"

def test_multiplex_active_scoped_missing_key_is_empty(self, monkeypatch):
"""A scope without the key must NOT fall through to another profile's env.

If the secondary profile hasn't configured a recovery key, the scope is
authoritative: we return empty rather than silently borrowing the
default profile's key (which would fail verification with a confusing
"Key MAC does not match").
"""
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "default-profile-key")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"SOME_OTHER_KEY": "x"})
try:
assert _scoped_recovery_key() == ""
finally:
ss.reset_secret_scope(token)

def test_strips_whitespace(self, monkeypatch):
monkeypatch.setenv("MATRIX_RECOVERY_KEY", " padded-key \n")
assert _scoped_recovery_key() == "padded-key"

def test_unset_returns_empty(self, monkeypatch):
monkeypatch.delenv("MATRIX_RECOVERY_KEY", raising=False)
assert _scoped_recovery_key() == ""