Skip to content

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

Open
Olorunfemi20 wants to merge 2 commits into
codebestia:mainfrom
Olorunfemi20:feat/signal-migration-path
Open

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

Conversation

@Olorunfemi20

@Olorunfemi20 Olorunfemi20 commented Jul 30, 2026

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 has been reworked onto the devices.capabilities model that landed on main while the PR was open — see "Rebased onto the capability model" below for what was dropped and why.

What this builds on

main already has capability advertisement: devices.capabilities lists the protocols a device can decrypt, and selectProtocol (src/lib/capabilities.ts) picks the strongest one two devices share, falling back to the sealed_box baseline. GET /conversations/:id/devices already returns each device's capabilities and the negotiated protocol.

This PR adds the two things negotiation alone does not provide.

1. A record of what actually encrypted each envelope

message_envelopes.protocole2ee_protocol NOT NULL DEFAULT 'sealed_box' (enum mirroring KNOWN_PROTOCOLS). Adding it with that default backfills every existing row in the same statement.

This is the no-history-loss guarantee. Capabilities describe what a device can do now, which says nothing about what encrypted a message last month — so capability alone cannot be used to pick a decryption path for old data. Recording the construction per envelope is what keeps pre-cutover history decryptable forever.

Every read path reports it (GET /sync, message_envelope socket events, conversation history), so 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 routine. Nothing is migrated or re-encrypted.

2. Enforcement on the way in

A sender can claim any protocol on an envelope. Both send paths (REST POST /messages and socket send_message) now reject two things:

Status Reason Meaning
400 unsupported_by_recipient The envelope names a protocol the recipient does not advertise. It would be undecryptable on arrival, which the recipient cannot distinguish from tampering.
409 downgrade Both devices can do better. Without this a patched client could quietly keep a peer on the sealed box forever and nobody would notice.

Both list every offending envelope with declared, expected and reason, so the client re-fetches the device set and rebuilds rather than retrying blind. When a batch contains both kinds, the undecryptable one decides the status code — it is the more specific failure.

MLS group messages carry a single group ciphertext and no per-device envelopes (#372), so the check does not run for them.

Rebased onto the capability model

Dropped from the original version of this PR, because main now covers them:

  • devices.supports_signal. A bespoke boolean alongside capabilities.protocols would be two sources of truth for "can this device do Signal" — exactly the divergence the repo already fixed once when user_devices was merged into devices.
  • PATCH /devices/:id/capabilities. POST /auth/verify already treats a newer capabilities document as the upgrade path.
  • GET /conversations/:id/e2ee-protocol. GET /conversations/:id/devices already answers this per device.

Changed:

  • Negotiation is now per device pair, not per conversation. My original version held the whole conversation on sealed box until every device was ready. Since each recipient device already gets its own envelope, that was strictly worse: an upgraded pair can use Signal immediately instead of waiting for the slowest device in the group, which in a large group may be never. main's pairwise selectProtocol is the better primitive and this now enforces exactly what it returns.

Net effect: the PR is smaller and composes with what shipped, instead of competing with it.

Migration

Renumbered to drizzle/0004_envelope_protocol.sql (00010003 are taken on main). Existing _journal.json entries are left byte-identical — only a new entry is appended.

Separate problem, flagged not fixed: drizzle/meta/_journal.json on main has one entry packing three when/tag pairs as duplicate keys, so 0001_mls_group_state and 0001_mls_key_packages are silently dropped when it is parsed, and nine of the twelve migration files are unjournaled entirely. Repairing that means asserting an apply order for other people's migrations, which is a maintainer call and does not belong in this PR.

Merge repairs

Several files this branch touches do not compile on main after earlier merges — duplicated blocks and lost declarations from conflict resolutions. They are fixed here because the branch cannot build otherwise:

  • schemas/message.schemas.tsSendMessageSchema declared twice
  • routes/messages.ts — the request body destructured twice
  • socket/messaging.ts — sibling-coverage check duplicated with an unbalanced brace; send_file_message lost its payload destructuring
  • db/schema.tsmlsKeyPackages and deviceKeyHistory spliced into one another
  • services/deliveryPipeline.tswelcomeTransport referenced without being declared

Each repair preserves both merged features; nothing from main is dropped. The removals in the socket/messaging.ts diff are exactly the duplicated and now-dead code.

main is also broken in routes/users.ts, routes/conversations.ts, routes/files.ts, routes/uploads.ts and routes/devices.ts, which this branch does not touch. Those are left alone deliberately — users.ts in particular has a PATCH /me handler fused with a bogus duplicate route referencing undefined variables, and involves privacy-settings work I was not part of. That needs its own PR; I'm happy to open one.

Refactor

routes/messages.ts now uses the shared insertMessageEnvelopes helper that the socket paths already use, instead of its own copy of the resolve-devices-and-insert logic. The per-envelope protocol default lives in one place, so the two implementations cannot drift apart again — which is what produced the duplication above.

Tests

  • e2eeProtocol.test.ts — 13 cases: sealed box accepted for a Phase-1 recipient, Signal accepted when both advertise it, Signal-to-Phase-1 rejected, post-cutover downgrade rejected, a mixed batch where one pair uses Signal and another stays on sealed box (the per-pair property), undecryptable reported ahead of downgrade, and null/absent capability documents treated as baseline at both ends.
  • signalMigration.routes.test.ts — 8 cases over POST /messages: protocol defaulting, persistence, both rejection codes with their violations, unknown protocol rejected at the schema layer, membership checked first, and the check skipped for envelope-free MLS messages.

75 tests pass across my four suites. tsc and eslint are clean on every file this branch touches.

Docs

apps/backend/docs/signal-migration.md rewritten for the capability model: what it builds on, the three rules, why negotiation is per-pair, the data model, advertising support via re-verify, both rejection codes, the read contract, a per-pair cutover diagram, and the rollout order — including that dual-path decryption must ship before Signal encryption.

@
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

@codebestia

Copy link
Copy Markdown
Owner

Please resolve the conflicts.

@
Merge upstream/main into feat/signal-migration-path

Rebuilds the Phase-1 to Signal migration on the device capability model
that landed on main while this branch was open, and repairs merge damage
in the files it touches.

Reworked onto devices.capabilities:

- drops the bespoke devices.supports_signal flag. main gained a
  capabilities jsonb document listing the protocols a device can decrypt,
  plus selectProtocol() to pick the strongest one two devices share. Two
  sources of truth for "can this device do Signal" is exactly the kind of
  divergence the repo already fixed once when user_devices was merged into
  devices, so the flag is gone and negotiation reads capabilities
- drops PATCH /devices/:id/capabilities. POST /auth/verify already treats
  a newer capabilities document as the upgrade path
- drops GET /conversations/:id/e2ee-protocol. GET /conversations/:id/devices
  already returns each device capabilities and the negotiated protocol
- negotiation is now per device pair rather than per conversation. Since
  each recipient device already gets its own envelope, an upgraded pair can
  use Signal immediately instead of waiting for the slowest device in the
  conversation, which in a large group may never happen

What this branch still adds, because main has no equivalent:

- message_envelopes.protocol, NOT NULL DEFAULT sealed_box, backfilling
  every existing row. Capabilities describe what a device can do now, which
  cannot interpret an envelope written months ago; recording the
  construction per envelope is what keeps pre-cutover history decryptable
- send-path enforcement on both REST and socket: an envelope naming a
  protocol the recipient does not advertise is refused with 400, and one
  weaker than both sides support with 409, each listing the offending
  envelopes
- protocol reported on every read path so a client catching up across the
  cutover decrypts each envelope with the matching routine

Migration renumbered to 0004_envelope_protocol.sql; existing journal
entries left byte-identical.

Repairs to files this branch touches, which do not compile on main after
earlier merges:

- schemas/message.schemas.ts declared SendMessageSchema twice
- routes/messages.ts destructured the request body twice
- socket/messaging.ts had the sibling-coverage check duplicated with an
  unbalanced brace, and send_file_message lost its payload destructuring
- db/schema.ts had the mlsKeyPackages and deviceKeyHistory tables spliced
  into one another
- services/deliveryPipeline.ts referenced welcomeTransport without
  declaring it

routes/messages.ts now uses the shared insertMessageEnvelopes helper the
socket paths already use, so the per-envelope protocol default cannot
drift between the two implementations again.
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

2 participants