OpenE2EE

Migrate from libsignal-protocol-javascript or @privacyresearch/libsignal-protocol-typescript

A re-architecture guide with no wire compatibility, an honest comparison of the packages you are leaving, and a staged cutover that does not require a flag day.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node
Prereqs
An existing deployment on a JavaScript Signal Protocol library
Reading time
17 min

This is a re-architecture, not a dependency swap

There is no wire compatibility between @open-e2ee/signal-protocol-sdk and either libsignal-protocol-javascript or @privacyresearch/libsignal-protocol-typescript. Existing sessions do not migrate. Stored ratchet state does not migrate. Ciphertext produced by one is not readable by the other. An import change or compatibility shim cannot solve this mismatch. The packages use different key-agreement and message formats, not only different APIs.

If you expected a dependency swap, stop and revise the plan. Teams can otherwise start an afternoon task and later discover that they must re-establish every production session.

Budget a re-architecture: new storage layer, new key distribution, a cutover plan, and a user-visible safety-number change for every conversation. The rest of this page is how to do that without a flag day.

Intended audience

This guide is for teams that run a JavaScript Signal Protocol implementation in production. They need post-quantum key agreement, groups, active maintenance, React Native or Expo, or another licensing path.

Prerequisites

  • A working deployment on one of the packages below, with its own key-distribution backend.
  • Read Keys, identity, and sessions: the concept mapping assumes it.
  • The ability to ship two client versions and run them side by side for a period measured in weeks.
A device outline with one attached local store holding its identity key, a seal gutter, and a relay bracket carrying an outlined readable prekey bundle above an opaque sheared envelopedevice + one storage adapteridentity keysealprekey bundlerelay: prekeys and envelopes
The target design replaces four custom store interfaces with one local store. The public prekey bundle can travel. The envelope stays opaque while its size and timing remain visible.

Install

Install alongside your existing library. Nothing here removes it:

npm install @open-e2ee/signal-protocol-sdk@0.1.0

On Expo, the storage adapter and the bootstrap vault need peer packages:

npm install @open-e2ee/signal-protocol-sdk@0.1.0 expo-secure-store expo-sqlite

The Expo store requires a development build and is not available in Expo Go. Keep the old package in package.json until stage 4 below.

Starting points

Every package below is a real project that did real work. The dates are from the GitHub and npm registry APIs, measured 2026-07-24. Overstating a competitor's condition would be a worse failure than saying nothing.

signalapp/libsignal-protocol-javascript

Archived. Last push 2021-08-04. 1,960 stars, 19 issues frozen at archive time. Its own repository description reads: "This library is no longer maintained."

It remains the top search result for "signal protocol javascript" today.

A tutorial updated in January 2026 still uses this description:

"the official JavaScript implementation." The package lost that status in 2021 and received no security fix in five years.

@signalapp/libsignal-client: the official replacement

Actively maintained: v0.99.1 published 2026-07-23, roughly 125,000 downloads per month. This is the implementation Signal Messenger itself uses, and it is healthy, well-engineered software.

It is also a Node native addon. Prebuilt binaries cover only Windows, macOS, and Debian-flavoured Linux. It has no iOS, Android, WASM, or browser build. Hermes has no N-API, so React Native cannot use this addon.

Its README states:

"Use outside of Signal is unsupported. In particular... All APIs and implementations are subject to change without notice."

The license is AGPL-3.0 with no commercial option.

This description defines scope, not quality. @signalapp/libsignal-client is a viable choice for a Node server on supported Linux when you can meet AGPL obligations. It does not target browsers, phones, or closed-source products.

@privacyresearch/libsignal-protocol-typescript

Last npm publish 2023-05-06. Last repository push 2023-07-18. GPL-3.0-only. Roughly 27,128 downloads in June 2026, so it is still carrying real deployments.

No sender keys and no group support. No PQXDH, so no post-quantum key agreement. No README statement about audit status either way.

Issue #92 opened in June 2026 and remains open. Its title is "Possible inbound PreKey trust-check bypass : isTrustedIdentity() Promise is not awaited in SessionBuilder.processV3()." The issue concerns an unmaintained package and the inbound prekey trust path. It does not confirm exploitation. The 2023 maintenance dates make a fix unlikely.

WASM is not the escape hatch

The common plan: compile a Rust or C implementation to WebAssembly and run it everywhere: does not hold on React Native. The failure is concrete:

Error: Unable to bind Webassembly to React Native JSI., js engine: hermes

react-native-webassembly (405 stars) has had no commits since 2023-11-03. Budget this as an unsolved problem rather than an integration task.

Comparison

Expo / React NativeBrowserMaintainedPost-quantumTypeScript-nativeCommercial license
@open-e2ee/signal-protocol-sdkYesYesYes — 0.1.x, activeYes — PQXDH + ML-KEM, default and fails closedYesYes
@signalapp/libsignal-clientNo — prebuilds for Windows, macOS, Debian-flavoured Linux onlyNoYes — very active, v0.99.1 on 2026-07-23YesNo — Rust core with TypeScript bindingsNo — AGPL-3.0 only
libsignal-protocol-javascriptNoYesNo — archived, last push 2021-08-04NoNo — JavaScriptNo — GPL-3.0
@privacyresearch/libsignal-protocol-typescriptNo documented pathYesNo — last publish 2023-05-06, last push 2023-07-18NoYesNo — GPL-3.0

What does not carry over

Be specific with your team about this list, because each line is work.

  • Sessions. Every session must be re-established from a fresh prekey bundle. There is no import path for an existing ratchet.
  • Ratchet state. Chain keys, message numbers, and skipped-message keys are gone. The new client cannot decrypt messages in flight under the old format after cutover.
  • Stored keys. The SDK regenerates identity keys, signed prekeys, and one-time prekeys. Your users' identity keys change.
  • Wire format. PQXDH with ML-KEM-1024 is not X3DH. The SDK requires exactly 0x0A || raw ML-KEM-1024 bytes for public keys and ciphertexts, and postQuantum: 'required' is the default.
  • Your key-distribution backend's contract. It becomes an ISignalProtocolRelayServer implementation with different responsibilities.

What users experience. Every conversation shows a safety-number change on the first message after cutover, because every identity key is new. If your product surfaces identity changes, and it should, you will produce one for every contact of every migrating user, at once. Plan the copy before you plan the code. See Identity changes and safety numbers for why a mass identity-change event is exactly where warning fatigue does damage.

The cutover does not affect message history because it is your application's data. Decrypted rows stay where they are. Only the ability to continue an existing cryptographic session ends.

Concept mapping

Four store interfaces become one adapter. Both older packages require custom identity, prekey, signed-prekey, and session stores. You must keep those four stores consistent. This SDK requires one adapter:

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { expoStore } from '@open-e2ee/signal-protocol-sdk/local/store/expo';

const client = await createSignalProtocolClient({
  identity: { userId },
  adapters: { storage: expoStore({ relay }), relay },
});

ISignalProtocolLocalStore is the single required adapter, and shipped implementations exist for Expo (/local/store/expo, primary supported), Node (/local/store/node), the browser (/local/store/web), bare React Native (/local/store/react-native, over a key-value backend you supply), and development (/local/store/memory, development only). Your hand-written stores become configuration rather than code.

Your key distribution becomes ISignalProtocolRelayServer. Both older packages leave prekey upload, bundle fetch, and one-time-prekey consumption to you. This SDK defines an adapter contract with explicit responsibilities. It covers key uploads, device records, envelope delivery, provisioning state, and stale-device cleanup. Bundle fetch "must not hand out the same one-time prekey as if it were still unused." Replenishment is a call:

await client.syncToServer();

const status = await client.checkPreKeyStatus();
// { oneTimePreKeysRemaining, needsReplenishment }

ONE_TIME_PREKEY_BATCH_SIZE issues 100 prekeys, and MAX_EC_PREKEYS limits EC prekeys to 200. preKeyLowThreshold defaults to 50. keyRefreshIntervalMs defaults to 172800000 milliseconds (2 days). An existing bundle backend can become an ISignalProtocolRelayServer implementation. See relay and prekeys.

Group support adds two APIs. @privacyresearch/... has no sender keys. This SDK can distribute a sender key once for each member, then call encryptGroupMessage(groupId, plaintext) and decryptGroupMessage(groupId, senderId, senderDeviceId, framedMessage). GroupsV2 also adds encrypted group state. The backend "stores opaque encrypted group state and sequences changes," while member devices keep the secrets. See groups.

Post-quantum support becomes the default. protocol.postQuantum defaults to 'required'. 'compatible' is an explicit opt-in and "does not allow downgrade recovery." PQXDH follows Revision 3 from 2023-05-24, last updated 2024-01-23. "PQXDH uses standardized FIPS 203 ML-KEM-1024 behavior." This statement describes the algorithm, not FIPS validation. Pure JavaScript cryptography is not FIPS 140-validated.

What you gain

  • Post-quantum key agreement by default, failing closed rather than downgrading.
  • Sender-key groups and GroupsV2 encrypted group state.
  • Multi-device through Sesame. The backend allocates linked device IDs 2 through 5, with at most 5 devices for each user.
  • Sealed sender, within its documented limits.
  • Encrypted attachments through a brokered object store: see Encrypted attachments with R2 and S3.
  • Safety numbers with an authenticated confirmation token, and acceptIdentityRotation() for handling change explicitly.
  • Six direct production dependencies, resolving to six packages in total, and a public CI running npm ci, build, typecheck, and npm audit --omit=dev. The published assurance run on 2026-08-10 covered 384 modules and 6,893 assertions, 2 skipped, 0 failed.
  • A commercial license option. The SDK is AGPL-3.0-or-later, with a proprietary path. See licensing.

A staged cutover that is not a flag day

You cannot decrypt old-format messages with the new client, so the plan is not "migrate", it is "run both and let conversations move on next contact."

Stage 1: ship both, send with neither. Add the SDK alongside the existing library. Initialize it, create storage, and call syncToServer() so the user has fresh keys published, but keep sending on the old path. Nothing user-visible changes. Validate key generation, storage, and prekey upload on every runtime you support.

const client = await createSignalProtocolClient({
  identity: { userId },
  adapters: { storage: expoStore({ relay }), relay },
});
await client.syncToServer();

Stage 2: receive on both. Route inbound traffic by format. Your relay knows which envelopes it carries. Tag them at the transport layer rather than sniffing bytes. Old-format envelopes go to the old library, new-format envelopes to client.processIncomingEnvelopes(). Both write into the same application message table, because your decrypted rows were never the library's data.

Stage 3: migrate a conversation on next contact. When a user sends to a peer, check for that peer's new-format keys. If those keys exist, establish a session with the SDK and send on the new path. That action migrates the conversation. Otherwise, send on the old path. Migration follows real usage, so active conversations move in days and dormant ones never need to.

Stage 4: set a cutoff and retire the old path. Pick a date past which the old library no longer sends. Keep it receiving for at least as long again, because in-flight messages exist. Then remove it.

In-flight messages. The old library can decrypt a delayed message while it retains the session state. Therefore, stage 4 continues receiving after it stops sending. Do not delete the old storage when you stop sending. Delete it after the receive window closes. Some messages can still be lost, so build a gap marker before cutover.

Trust boundaries: what crosses which line

The boundaries change shape when you migrate, and it is worth restating them because the old packages left all three to you.

Device to relay. Envelopes cross sealed. Public identity keys and prekey bundles cross because they must be fetchable. The relay never needs message plaintext or device private keys. Under your old DIY backend this boundary existed only as a convention in your code. Here it is an adapter contract.

Device to application storage. Decrypted rows and local files are yours after the client decrypts them. This does not change, and it is why message history survives migration.

Library to application. "The client owns protocol coordination. The host application owns persistence, authentication, authorization, and product policy." Your four custom store interfaces implemented this boundary four times.

What the backend can see

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
Old library's session stateyesnon/ano
Old library's stored identity key (private)yesnon/ano
New identityKeyPair private halfyesnon/ano
New identityKeyPair public halfyesyesn/ayes — visibly a new key
Signed prekey (private half)yesnon/ano
Signed prekey (public half)yesyesn/ayes
One-time prekeys (public halves)yesyesn/acount and consumption rate
ML-KEM prekeys (public halves)yesyesn/ayes — presence signals the new client
Message plaintextyesnon/ano
Message ciphertextyesyesn/aenvelope size, routing, arrival time
Session record (version: 4)yesnon/ano
Which protocol version a user is onyesyesn/ayes — unavoidable during cutover
Decrypted message rows (app-owned)yesnon/ano

The second-to-last row shows the migration-specific leak. While both paths run, the relay can identify clients on the new path because they publish different key material. That is inherent to a staged cutover, not a defect you can engineer away. It bounds how long you want stage 3 to last.

Failure and recovery behaviour

Production caveats

0.1.x; public APIs and persisted formats may change before 1.0. Persisted formats are the material risk in a migration: the SDK versions session records (version: 4) and rejects and resets older formats instead of migrating them. A format change before 1.0 could cost your users a second round of session re-establishment. Weigh that against the state of the package you leave.

The SDK is reviewed continuously by adversarial AI agents; it is not audited by any independent firm. Neither libsignal-protocol-javascript nor @privacyresearch/libsignal-protocol-typescript has a published audit. You must still complete your own security review.

The Expo store is the primary supported adapter and requires a development build; it is not available in Expo Go. The browser store is supported; a deployment requires the browser threat-model review. The bare React Native store is supported over a key-value backend you supply and verify with the exported backend-conformance kit. The in-memory store and in-memory relay are development only.

Next

On this page