Skip to content

Commit b1d339f

Browse files
authored
Merge branch 'main' into fix/key-backup-honest-count
2 parents 155ac00 + b50bc29 commit b1d339f

5 files changed

Lines changed: 205 additions & 17 deletions

File tree

skills/matrix-communication/references/setup-guide.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,43 @@ uv run skills/matrix-communication/scripts/matrix-key-backup.py --recovery-key "
166166

167167
## Troubleshooting
168168

169+
**A token you found somewhere is valid — that does not make it yours.**
170+
171+
When the configured token stops working, the tempting next step is the token
172+
lying in a secrets file, an env var, or another tool's config. It authenticates,
173+
`whoami` answers, calls succeed. It can still be the wrong credential, because a
174+
token carries a `device_id` and that device may be a human's running client.
175+
176+
Before adopting any credential you did not create here:
177+
178+
```bash
179+
HS=$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.config/matrix/config.json')))['homeserver'])")
180+
curl -s -H "Authorization: Bearer $TOKEN" "$HS/_matrix/client/v3/account/whoami"
181+
curl -s -H "Authorization: Bearer $TOKEN" "$HS/_matrix/client/v3/devices" \
182+
| python3 -c "import json,sys;[print(d['device_id'], '-', d.get('display_name')) for d in json.load(sys.stdin)['devices']]"
183+
```
184+
185+
If the `device_id` belongs to a device whose display name reads like a human
186+
client — "Element Desktop: Windows", "Element X Android", "FluffyChat android" —
187+
stop. That is someone's session, not an agent credential.
188+
189+
What happens if you use it anyway: the nio store creates its own olm account for
190+
that device id, so one device now has two crypto identities. The client that
191+
owns it starts failing to decrypt messages, including its own, and SAS
192+
verification against the account's other devices fails with "expected key did
193+
not match" — even after switching to a proper device, because the damage is on
194+
the server-side device keys.
195+
196+
The only sanctioned path to an agent credential is `matrix-e2ee-setup.py`, which
197+
logs in fresh and gets a device of its own.
198+
199+
**Recovery, if a foreign token was already used with the E2EE scripts:** treat
200+
that device's crypto identity as spent. Log the agent device out
201+
(`matrix-e2ee-setup.py --logout`, which touches only that device's files), set
202+
up a fresh one, re-import the room keys, and verify again. The human client
203+
whose device was hijacked needs a logout and a fresh login of its own; its local
204+
session state cannot be repaired from this side.
205+
169206
**E2EE setup fails with "Invalid username or password":**
170207

171208
If your password contains special characters (`!`, `$`, `\`, etc.), bash may mangle them:

skills/matrix-communication/scripts/_lib/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
load_credentials,
2828
restore_login_checked,
2929
save_credentials,
30+
store_files_for,
3031
)
3132

3233
# Formatting
@@ -88,5 +89,6 @@
8889
"save_credentials",
8990
# Formatting
9091
"shorten_service_urls",
92+
"store_files_for",
9193
"suppress_nio_logging",
9294
]

skills/matrix-communication/scripts/_lib/e2ee.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,52 @@ def restore_login_checked(client, user_id: str, device_id: str, access_token: st
114114
raise SystemExit(f"Error: {hint}") from exc
115115

116116

117-
def delete_credentials():
118-
"""Remove stored device credentials and key store files."""
117+
def store_files_for(user_id: str, device_id: str) -> list[Path]:
118+
"""Every store file belonging to one device.
119+
120+
nio names them ``{user_id}_{device_id}.<suffix>`` - the database plus the
121+
blacklisted/ignored/trusted device lists. The trailing dot matters: without
122+
it a device id that is a prefix of another would collect the other's files
123+
too.
124+
"""
125+
prefix = f"{user_id}_{device_id}."
126+
return sorted(p for p in get_store_path().iterdir() if p.name.startswith(prefix))
127+
128+
129+
def delete_credentials(purge_all: bool = False) -> list[str]:
130+
"""Remove the stored credentials and the store files of THAT device.
131+
132+
Returns the names of the files removed, so the caller can say what it did.
133+
134+
The store directory is shared by every device ever set up here. Globbing
135+
``*.db`` and ``*_devices`` across it - which this did - means logging one
136+
device out destroys the megolm history of all the others. That happened:
137+
a logout of a broken device took a months-old 25 MB store with it, and only
138+
a server-side key backup made the rooms readable again.
139+
140+
``purge_all`` restores the old sweep for the case where you do want the
141+
directory emptied. ``backup_key.json`` is never touched either way; it is
142+
not device-scoped and re-importing keys depends on it.
143+
"""
144+
removed: list[str] = []
145+
creds = load_credentials()
119146
creds_path = get_credentials_path()
147+
148+
if purge_all:
149+
store_path = get_store_path()
150+
targets = sorted([*store_path.glob("*.db"), *store_path.glob("*_devices")])
151+
elif creds and creds.get("user_id") and creds.get("device_id"):
152+
targets = store_files_for(creds["user_id"], creds["device_id"])
153+
else:
154+
# No credentials to scope by. Removing nothing beats removing everything.
155+
targets = []
156+
157+
for path in targets:
158+
path.unlink()
159+
removed.append(path.name)
160+
120161
if creds_path.exists():
121162
creds_path.unlink()
163+
removed.append(creds_path.name)
122164

123-
# Also remove key store databases
124-
store_path = get_store_path()
125-
for db_file in store_path.glob("*.db"):
126-
db_file.unlink()
127-
for key_file in store_path.glob("*_devices"):
128-
key_file.unlink()
165+
return removed

skills/matrix-communication/scripts/_lib/test_e2ee.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for `_lib.e2ee` store-error diagnosis.
1+
"""Tests for `_lib.e2ee`: store-error diagnosis and scoped credential deletion.
22
33
The skill directory contains a hyphen (`matrix-communication`) so it is not
44
importable as a package; run the file directly or use unittest discovery:
@@ -16,13 +16,23 @@
1616
package and breaks `urllib` on the way in.
1717
"""
1818

19+
import json
1920
import os
21+
import pathlib
22+
import shutil
2023
import sys
24+
import tempfile
2125
import unittest
2226

2327
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
2428

25-
from e2ee import explain_store_error, restore_login_checked
29+
import e2ee
30+
from e2ee import (
31+
delete_credentials,
32+
explain_store_error,
33+
restore_login_checked,
34+
store_files_for,
35+
)
2636

2737

2838
class OlmAccountError(Exception):
@@ -86,5 +96,78 @@ def test_unrelated_error_is_reraised_untouched(self):
8696
restore_login_checked(client, "@u:example.org", "DEVICE", "syt_token")
8797

8898

99+
class StoreScopingTests(unittest.TestCase):
100+
"""--logout must take one device's files and leave every other device alone.
101+
102+
Regression for #81: the old code globbed `*.db` and `*_devices` across the
103+
shared store directory, so logging one device out destroyed the megolm
104+
history of all of them.
105+
"""
106+
107+
USER = "@user:example.org"
108+
MINE = "DEVICEAAAA"
109+
OTHER = "DEVICEBBBB"
110+
111+
def setUp(self):
112+
self.store = pathlib.Path(tempfile.mkdtemp())
113+
self.addCleanup(shutil.rmtree, self.store, True)
114+
real = e2ee.get_store_path
115+
e2ee.get_store_path = lambda: self.store
116+
self.addCleanup(setattr, e2ee, "get_store_path", real)
117+
118+
for device in (self.MINE, self.OTHER):
119+
for suffix in (
120+
"db",
121+
"blacklisted_devices",
122+
"ignored_devices",
123+
"trusted_devices",
124+
):
125+
(self.store / f"{self.USER}_{device}.{suffix}").write_text("x")
126+
127+
# Not device-scoped, and the key import depends on it.
128+
(self.store / "backup_key.json").write_text("{}")
129+
(self.store / "credentials.json").write_text(
130+
json.dumps({"user_id": self.USER, "device_id": self.MINE})
131+
)
132+
133+
def _names(self):
134+
return sorted(p.name for p in self.store.iterdir())
135+
136+
def test_store_files_for_selects_one_device(self):
137+
names = sorted(p.name for p in store_files_for(self.USER, self.MINE))
138+
self.assertEqual(len(names), 4)
139+
self.assertTrue(all(self.MINE in n for n in names))
140+
141+
def test_store_files_for_does_not_match_a_prefix_device_id(self):
142+
"""A device id that is a prefix of another must not collect its files."""
143+
(self.store / f"{self.USER}_{self.MINE}EXTRA.db").write_text("x")
144+
names = [p.name for p in store_files_for(self.USER, self.MINE)]
145+
self.assertNotIn(f"{self.USER}_{self.MINE}EXTRA.db", names)
146+
147+
def test_logout_removes_only_this_device(self):
148+
removed = delete_credentials()
149+
150+
self.assertIn("credentials.json", removed)
151+
self.assertEqual(len([n for n in removed if self.MINE in n]), 4)
152+
153+
left = self._names()
154+
self.assertEqual(len([n for n in left if self.OTHER in n]), 4)
155+
self.assertIn("backup_key.json", left)
156+
self.assertNotIn("credentials.json", left)
157+
158+
def test_purge_all_removes_every_device(self):
159+
delete_credentials(purge_all=True)
160+
left = self._names()
161+
self.assertEqual([n for n in left if n.endswith("_devices")], [])
162+
self.assertEqual([n for n in left if n.endswith(".db")], [])
163+
self.assertIn("backup_key.json", left)
164+
165+
def test_without_credentials_nothing_is_removed(self):
166+
"""No credentials means no device to scope by - deleting nothing is right."""
167+
(self.store / "credentials.json").unlink()
168+
self.assertEqual(delete_credentials(), [])
169+
self.assertEqual(len(self._names()), 9)
170+
171+
89172
if __name__ == "__main__":
90173
unittest.main(verbosity=2)

skills/matrix-communication/scripts/matrix-e2ee-setup.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@
1818
matrix-e2ee-setup.py PASSWORD # Password as argument
1919
matrix-e2ee-setup.py --status
2020
matrix-e2ee-setup.py --logout
21+
matrix-e2ee-setup.py --logout --purge-all
2122
matrix-e2ee-setup.py --help
2223
2324
Options:
24-
--status Check if E2EE device is set up
25-
--logout Remove stored device credentials
26-
--help Show this help
25+
--status Check if E2EE device is set up
26+
--logout Remove the credentials and this device's store files
27+
--purge-all With --logout: wipe every other device's store files too
28+
--help Show this help
29+
30+
--logout is scoped to the device in credentials.json. The store directory is
31+
shared by every device ever set up here, and their megolm history lives in it.
2732
"""
2833

2934
import asyncio
@@ -138,7 +143,15 @@ def main():
138143
)
139144
parser.add_argument("--status", action="store_true", help="Check E2EE setup status")
140145
parser.add_argument(
141-
"--logout", action="store_true", help="Remove stored device credentials"
146+
"--logout",
147+
action="store_true",
148+
help="Remove the stored credentials and this device's store files",
149+
)
150+
parser.add_argument(
151+
"--purge-all",
152+
action="store_true",
153+
help="With --logout: also delete the store files of every OTHER device "
154+
"in the store directory (destroys their megolm history)",
142155
)
143156
parser.add_argument("--json", action="store_true", help="Output as JSON")
144157
parser.add_argument("--debug", action="store_true", help="Show debug info")
@@ -169,11 +182,27 @@ def main():
169182
if args.logout:
170183
creds = load_credentials()
171184
if creds:
172-
delete_credentials()
185+
removed = delete_credentials(purge_all=args.purge_all)
173186
if args.json:
174-
print(json.dumps({"success": True, "message": "Credentials removed"}))
187+
print(
188+
json.dumps(
189+
{
190+
"success": True,
191+
"message": "Credentials removed",
192+
"device_id": creds.get("device_id"),
193+
"removed": removed,
194+
}
195+
)
196+
)
175197
else:
176-
print("E2EE device credentials removed.")
198+
scope = (
199+
"every device in the store"
200+
if args.purge_all
201+
else f"device {creds.get('device_id')}"
202+
)
203+
print(f"E2EE credentials removed for {scope}:")
204+
for name in removed:
205+
print(f" {name}")
177206
print("Note: The device still exists on the server.")
178207
print("To fully remove it, go to Element > Settings > Sessions")
179208
else:

0 commit comments

Comments
 (0)