fix(matrix): reset Olm crypto store when the access token's device ID changes
The crypto store is keyed by Matrix user ID, not device ID, so swapping in a new access token (which mints a new device_id) silently inherits the previous device's Olm account. That account's identity keys can never be published under the new device ID, and the pickle key embeds the old device ID anyway — the result is stale-key mismatches and cross-signing signatures the homeserver refuses to replace, degrading E2EE in ways that are hard to diagnose (peers silently withhold room keys). _reset_crypto_store_if_device_changed() compares the store's persisted device ID against the live one at connect time and wipes the store on mismatch, so a fresh Olm account is generated for the new device instead of reusing stale key material. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
799102a4d8
commit
a4b686c29e
|
|
@ -1329,6 +1329,37 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
return False
|
||||
return True
|
||||
|
||||
async def _reset_crypto_store_if_device_changed(
|
||||
self, crypto_store: Any, device_id: str
|
||||
) -> bool:
|
||||
"""Reset the local Olm account when the access token's device changed.
|
||||
|
||||
The crypto store is keyed by user ID, so a new access token (= new
|
||||
device ID) would otherwise inherit the previous device's Olm account.
|
||||
Its identity keys can never be published under the new device ID
|
||||
(and the pickle key embeds the old device ID anyway), which leads to
|
||||
stale-key mismatches and cross-signing signatures that the
|
||||
homeserver refuses to replace. Returns True if the store was reset.
|
||||
"""
|
||||
if not device_id:
|
||||
return False
|
||||
try:
|
||||
stored_device_id = await crypto_store.get_device_id()
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: could not read stored device ID: %s", exc)
|
||||
return False
|
||||
if not stored_device_id or stored_device_id == device_id:
|
||||
return False
|
||||
logger.warning(
|
||||
"Matrix: access token belongs to a new device (%s -> %s) — "
|
||||
"resetting local Olm account so fresh identity keys are "
|
||||
"generated for this device",
|
||||
stored_device_id,
|
||||
device_id,
|
||||
)
|
||||
await crypto_store.delete()
|
||||
return True
|
||||
|
||||
async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:
|
||||
"""Verify our device keys are on the homeserver after loading crypto state.
|
||||
|
||||
|
|
@ -1608,6 +1639,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
await crypto_store.open()
|
||||
|
||||
if client.device_id:
|
||||
await self._reset_crypto_store_if_device_changed(
|
||||
crypto_store, client.device_id
|
||||
)
|
||||
await crypto_store.put_device_id(client.device_id)
|
||||
|
||||
crypto_state = _CryptoStateStore(state_store, self._joined_rooms, client)
|
||||
|
|
|
|||
|
|
@ -2862,3 +2862,54 @@ class TestMatrixDispatchSyncIsolation:
|
|||
|
||||
assert ran["ok"] is True # the sibling handler still ran
|
||||
assert "event handler failed" in caplog.text # failure surfaced, not swallowed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2EE crypto store reset on device change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCryptoStoreResetOnDeviceChange:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_when_device_id_changed(self, caplog):
|
||||
import logging
|
||||
adapter = _make_adapter()
|
||||
store = MagicMock()
|
||||
store.get_device_id = AsyncMock(return_value="OLDDEVICE")
|
||||
store.delete = AsyncMock()
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
reset = await adapter._reset_crypto_store_if_device_changed(store, "NEWDEVICE")
|
||||
|
||||
assert reset is True
|
||||
store.delete.assert_awaited_once()
|
||||
assert "OLDDEVICE" in caplog.text and "NEWDEVICE" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reset_when_device_id_same(self):
|
||||
adapter = _make_adapter()
|
||||
store = MagicMock()
|
||||
store.get_device_id = AsyncMock(return_value="SAMEDEVICE")
|
||||
store.delete = AsyncMock()
|
||||
|
||||
assert await adapter._reset_crypto_store_if_device_changed(store, "SAMEDEVICE") is False
|
||||
store.delete.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reset_on_fresh_store(self):
|
||||
adapter = _make_adapter()
|
||||
store = MagicMock()
|
||||
store.get_device_id = AsyncMock(return_value=None)
|
||||
store.delete = AsyncMock()
|
||||
|
||||
assert await adapter._reset_crypto_store_if_device_changed(store, "NEWDEVICE") is False
|
||||
store.delete.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reset_without_device_id(self):
|
||||
adapter = _make_adapter()
|
||||
store = MagicMock()
|
||||
store.get_device_id = AsyncMock(return_value="OLDDEVICE")
|
||||
store.delete = AsyncMock()
|
||||
|
||||
assert await adapter._reset_crypto_store_if_device_changed(store, "") is False
|
||||
store.delete.assert_not_awaited()
|
||||
|
|
|
|||
Loading…
Reference in New Issue