fix(matrix): honor profile secret scope for recovery key under multiplex
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
This commit is contained in:
parent
651c5160b7
commit
153442dd5b
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() == ""
|
||||
Loading…
Reference in New Issue