From 153442dd5b0d98cca8e44d53b80bbd9494dcea0d Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Wed, 22 Jul 2026 12:43:24 +0800 Subject: [PATCH] fix(matrix): honor profile secret scope for recovery key under multiplex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Matrix adapter read MATRIX_RECOVERY_KEY via os.getenv, so under gateway.multiplex_profiles every profile resolved the default profile's key. That produced "recovery key verification failed: Key MAC does not match" and broke E2EE for secondary profiles (#69090). Route the read through agent.secret_scope.get_secret, which honors the active profile's scope, with an os.getenv fallback for an unscoped read under multiplex (default-profile startup loop) — mirroring the Slack app-token pattern (#59739). Applied to both the startup verification site and the status diagnostic. Fixes #69090 --- plugins/platforms/matrix/adapter.py | 30 ++++++- .../gateway/test_matrix_recovery_key_scope.py | 79 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_matrix_recovery_key_scope.py diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 8ed47897e0fee..93926d21c39da 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -70,6 +70,8 @@ from html.parser import HTMLParser 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, @@ -813,6 +815,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: @@ -1577,7 +1597,11 @@ class MatrixAdapter(BasePlatformAdapter): 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) @@ -1875,7 +1899,9 @@ class MatrixAdapter(BasePlatformAdapter): "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), diff --git a/tests/gateway/test_matrix_recovery_key_scope.py b/tests/gateway/test_matrix_recovery_key_scope.py new file mode 100644 index 0000000000000..13478f0ef05d6 --- /dev/null +++ b/tests/gateway/test_matrix_recovery_key_scope.py @@ -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() == ""