Skip to content

feat(backend): Phase-1 to Signal migration path - #533

Open
Olorunfemi20 wants to merge 1 commit into
codebestia:mainfrom
Olorunfemi20:feat/signal-migration-path
Open

feat(backend): Phase-1 to Signal migration path#533
Olorunfemi20 wants to merge 1 commit into
codebestia:mainfrom
Olorunfemi20:feat/signal-migration-path

Conversation

@Olorunfemi20

Copy link
Copy Markdown
Contributor

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

  1. History is never re-encrypted. Envelopes written before the cutover keep protocol = 'sealed_box' and stay decryptable by the Phase-1 path forever. The cutover changes what is written next, never what was written before.
  2. A conversation cuts over only when every active device on every side advertises Signal support. A single un-upgraded device holds the whole conversation on sealed box, because the sender has to produce something that device can actually open.
  3. After cutover, sealed box is refused. Without this a patched or compromised client could keep a conversation on the weaker construction indefinitely and nobody would notice.

Data model

Migration drizzle/0001_signal_migration.sql, generated by drizzle-kit with its meta snapshot.

devices.supports_signal — the capability flag

boolean NOT NULL DEFAULT false. Every already-registered device starts at false, 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. PATCH with false after true returns 409.

message_envelopes.protocol — what actually encrypted this envelope

e2ee_protocol NOT NULL DEFAULT 'sealed_box' (enum sealed_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 same supportsSignal field on POST /devices and the device object of POST /auth/verify. All three paths only ever raise the flag.

GET /conversations/:id/e2ee-protocol
{
  "conversationId": "uuid",
  "protocol": "sealed_box",
  "totalActiveDevices": 3,
  "signalCapableDevices": 2,
  "blockingDevices": [
    { "deviceId": "uuid", "userId": "uuid", "deviceName": "old phone", "platform": "ios" }
  ]
}

blockingDevices exists 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, not signal. 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 negotiated protocol alongside the device list, each device carrying supportsSignal. 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 /messages and the socket send_message) reject two things, and they are different problems:

Status When Meaning
400 A signal envelope addressed to a device that cannot read it The sender got ahead of the recipient; that envelope would be undecryptable on arrival, which the recipient cannot distinguish from tampering
409 A sealed_box envelope after the conversation has cut over Downgrade; re-encrypt with Signal and retry

Both carry negotiatedProtocol and offendingDeviceIds so the client re-fetches the device set and rebuilds rather than retrying blind. The socket path emits the same fields on an error event with event: "protocol_mismatch".

Reading

GET /sync, the message_envelope socket event, and the conversation history endpoint all report protocol per envelope. A client catching up across the cutover receives a mix of sealed_box and signal envelopes 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 on main because web-push, socket.io-client and ioredis-mock are not installed in the local workspace; the failure set is unchanged. eslint src reports no errors.

Docs

apps/backend/docs/signal-migration.md covers 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 existing docs/signal-integration.md now 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 a 0001_* migration on their own branches. Whichever merges later needs its migration renumbered and its _journal.json entry re-indexed — the SQL itself is independent and does not overlap.

@
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.
@drips-wave

drips-wave Bot commented Jul 30, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

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.

Phase-1 → Signal migration path

1 participant