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:
ckaznocha 2026-07-25 12:36:03 -07:00 committed by kshitij
parent 799102a4d8
commit a4b686c29e
2 changed files with 85 additions and 0 deletions

View File

@ -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)

View File

@ -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()