Skip to content

A removed MLS device stays removed (GRYT-1555) - #249

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-1555-remove-means-removed
Sep 28, 2026
Merged

sivert-io merged 1 commit into
mainfrom
claude/GRYT-1555-remove-means-removed

Conversation

@sivert-io

@sivert-io sivert-io commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Removing a device deleted its row and nothing else. If the device was still signed in, its next mls:sync said registered: false, the driver published fresh KeyPackages under the same id, and whoever sent next added it back to the DM. Removing a lost phone that was still online only lasted until it reconnected.

What changes

  • mls:device:remove also writes the id to a new table, mls_removed_devices (server_user_id, device_id, removed_at), in the same transaction.
  • ownDevice() in the handler checks that table first. So every mls:* call naming a removed id gets device_removed: sync, KeyPackage publish, claim, commit, send and welcome ack.
  • touchMlsDevice refuses a removed id inside its transaction too. A publish that races the removal can't write the row back.
  • Claims can't hand out its KeyPackages. They're deleted when it's removed, and it can't publish more.
  • A new device id registers as usual, so setting the app up again makes a new device.
  • A guest merged into an account brings its removed ids along, next to its devices.

How long they're kept

For good. A phone left in a drawer can wait out any window we'd pick, and waiting it out is exactly the case this is for. A row is a few dozen bytes, and it only grows when somebody removes a device by hand, which is rate limited with the KeyPackage publish bucket. Users aren't deleted on this server except by a guest merge, which carries the rows over.

What to look at

  • The table and both queries in src/db/sqlite/**, which is review-required. The diff there is about 30 lines.
  • Behaviour change: mls:device:remove on an id that's already removed now answers device_removed instead of ok. The client's retire-after-clear path treats unknown_device as done and needs to treat device_removed the same way. That's in client#713.
  • This stops an honest app. A modified one still holds the seed and can make a new device id whenever it likes. The apps' half (core, then client#713 and mobile#271) stops on device_removed, wipes that server's MLS state and history, and waits for a sign-in or a recovery-key restore before it makes a new device.

Tests

  • DB: a removed id can't touch back in, a new id can, and it survives a guest merge.
  • Handler, the stolen-phone case: Hana removes her phone while it's online. The phone's sync, publish and claim each get device_removed. Ivar's claim only gets Hana's laptop, and mls:devices no longer lists the phone. A new device id publishes fine.
  • yarn test passes except voiceRecovery.test.ts, which timed out once under the full run and passes alone. eslint, build and the comment check are clean.

Docs: Gryt-chat/docs#148.

🤖 Generated with Claude Code

Removing a device used to delete its row, and nothing else stopped it.
If it was still signed in, its next sync said registered: false, the
driver published fresh KeyPackages under the same id, and whoever sent
next added it back to the DM. So removing a lost phone that was still
online did nothing for long.

mls:device:remove now writes the id to mls_removed_devices as well.
From then on every mls:* call from that device id gets device_removed:
sync, publish, claim, commit, send and welcome:ack. touchMlsDevice
refuses it too, so a publish racing the removal can't bring the row
back. Nothing it published is left to claim, since its KeyPackages go
with the removal and it can't publish more. A new device id registers
as usual, so setting the app up again makes a new device.

The rows are kept for good. Any window we'd pick is one a phone left in
a drawer can wait out, and a row is a few dozen bytes. There's no other
per-device limit to hit either. A guest merged into an account brings
its removed ids along, like it brings its devices.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@sivert-io
sivert-io merged commit 1c12370 into main Sep 28, 2026
6 checks passed
@sivert-io
sivert-io deleted the claude/GRYT-1555-remove-means-removed branch September 28, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant