feat(backend): Phase-1 to Signal migration path - #533
Open
Olorunfemi20 wants to merge 1 commit into
Open
Conversation
feat(backend): Phase-1 to Signal migration path Defines how existing sealed-box conversations move to Signal without losing history. Clients update at their own pace, so a conversation holds devices on both sides of the line until the slowest one catches up. Three rules, enforced server side: 1. History is never re-encrypted. message_envelopes.protocol is added NOT NULL DEFAULT sealed_box, which labels every pre-existing envelope with the construction that actually encrypted it in the same statement. A client always knows which decryption path to use instead of inferring it from the ciphertext or from a timestamp. 2. A conversation cuts over only when every active device on every side advertises Signal support. One un-upgraded device holds the whole conversation on sealed box, because the sender has to produce something that device can open. 3. After cutover a sealed-box envelope is refused, so a patched client cannot quietly keep everyone on the weaker construction. - devices.supports_signal is the capability flag, advertised via PATCH /devices/:id/capabilities or at registration. It is monotonic: a device may turn it on but not off, since withdrawal would be a downgrade lever indistinguishable from a genuine rollback. A device that lost its Signal state re-registers under a new identity key - GET /conversations/:id/e2ee-protocol reports the negotiated protocol and which devices are blocking the cutover, so the UI can name them - GET /conversations/:id/devices carries the negotiated protocol alongside the device set the client is about to encrypt for - both send paths reject a Signal envelope aimed at a device that cannot read it (400) and a sealed-box downgrade after cutover (409), each carrying the negotiated protocol and the offending device ids - sync, delivery and history read paths report protocol per envelope, so catching up across the cutover returns a mix and loses nothing Adds docs/signal-migration.md with the data model, endpoints, cutover timeline and rollout order, linked from docs/signal-integration.md.
|
@Olorunfemi20 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #364
Summary
Defines how existing Phase-1 sealed-box conversations transition to Signal without losing history. Clients update at their own pace, so a conversation contains devices on both sides of the line for as long as the slowest one takes — this PR makes that state explicit, safe, and observable.
The three rules
protocol = 'sealed_box'and stay decryptable by the Phase-1 path forever. The cutover changes what is written next, never what was written before.Data model
Migration
drizzle/0001_signal_migration.sql, generated by drizzle-kit with its meta snapshot.devices.supports_signal— the capability flagboolean NOT NULL DEFAULT false. Every already-registered device starts atfalse, so nothing cuts over until each device explicitly says it can.The flag is monotonic — a device may turn it on but not off. Accepting a withdrawal would hand any client a lever to pull the whole conversation back onto the Phase-1 construction, and the other side has no way to distinguish that from a genuine rollback. A device that really has lost its Signal state re-registers under a new identity key, which produces a new row starting at
false.PATCHwithfalseaftertruereturns409.message_envelopes.protocol— what actually encrypted this envelopee2ee_protocol NOT NULL DEFAULT 'sealed_box'(enumsealed_box | signal).Adding the column with that default backfills every existing row in the same statement. This is the no-history-loss guarantee: every envelope ever written is labelled with the construction that produced it, so a client picks the decryption path from the data rather than inferring it from the ciphertext or from when the message was sent. The column is per envelope, not per conversation, because a conversation legitimately contains both during the transition.
Capability negotiation
Devices advertise via
PATCH /devices/:id/capabilities(owner-only, idempotent), or at registration through the samesupportsSignalfield onPOST /devicesand thedeviceobject ofPOST /auth/verify. All three paths only ever raise the flag.{ "conversationId": "uuid", "protocol": "sealed_box", "totalActiveDevices": 3, "signalCapableDevices": 2, "blockingDevices": [ { "deviceId": "uuid", "userId": "uuid", "deviceName": "old phone", "platform": "ios" } ] }blockingDevicesexists so the UI can name which device is holding things up rather than showing an unexplained "still on legacy encryption" badge.An empty device set reports
sealed_box, notsignal. Treating "no devices, therefore no blockers" as a cutover would flip the conversation to a mode no device can read the moment one joins.GET /conversations/:id/devices— the call clients already make immediately before encrypting — now returns the negotiatedprotocolalongside the device list, each device carryingsupportsSignal. Reading capability and protocol from two separate requests would leave a window where the client encrypts for a device set that has since changed.Sending
Each envelope declares its protocol; it is optional and defaults to
sealed_box, so clients written before this change keep working untouched.Both send paths (REST
POST /messagesand the socketsend_message) reject two things, and they are different problems:400signalenvelope addressed to a device that cannot read it409sealed_boxenvelope after the conversation has cut overBoth carry
negotiatedProtocolandoffendingDeviceIdsso the client re-fetches the device set and rebuilds rather than retrying blind. The socket path emits the same fields on anerrorevent withevent: "protocol_mismatch".Reading
GET /sync, themessage_envelopesocket event, and the conversation history endpoint all reportprotocolper envelope. A client catching up across the cutover receives a mix ofsealed_boxandsignalenvelopes in one page and decrypts each with the matching path. Nothing is migrated, re-encrypted, or re-downloaded.Tests
e2eeProtocol.test.ts— 11 cases on negotiation and enforcement: one lagging device blocks the cutover for everyone, an empty device set does not cut over, a conversation with no members reports sealed box, Signal-to-Phase-1 is rejected, a post-cutover sealed-box fallback is rejected, and a mixed batch reports the more specific error first.signalMigration.routes.test.ts— 17 cases on the HTTP surface: capability advertisement including idempotence and the withdrawal refusal, ownership and revocation checks, the negotiation endpoint and its 403, the device-set response carrying protocol and capability, and the send path defaulting, persisting, and rejecting each protocol case in the right order relative to the membership check.Suite goes from 378 to 406 passing. The three files that fail (
file.messages,presence.typing,gateway.integration) already fail onmainbecauseweb-push,socket.io-clientandioredis-mockare not installed in the local workspace; the failure set is unchanged.eslint srcreports no errors.Docs
apps/backend/docs/signal-migration.mdcovers the data model, capability advertisement, negotiation, both send rejections, the read contract, a cutover timeline diagram, and a five-step rollout order. It notes that shipping dual-path decryption must fully precede shipping Signal encryption — a device that encrypts with Signal while talking to one that cannot decrypt it produces envelopes nobody can open. The existingdocs/signal-integration.mdnow links to it from a new "Migration path" section.Note on ordering
This branch adds migration
0001_signal_migration.sql. My PRs for #365 and #372 each add a0001_*migration on their own branches. Whichever merges later needs its migration renumbered and its_journal.jsonentry re-indexed — the SQL itself is independent and does not overlap.