Quickstart
Install the SDK and run a real encrypted round trip without deploying a backend.
- Status
- stable
- Applies to
- 7.1.0
- Platforms
- Node · Browser · Expo
- Prereqs
- Node 22.12+ and a package manager
- Reading time
- 6 min
This page has one goal. One identity encrypts a message, the ciphertext stays unreadable in transit, and another identity decrypts it. You do not need to deploy a server or create a hosted account. Once that runs, you know the protocol works in your runtime. Every other page explains how to replace the development pieces.
Try it before installation
Open the playground to run a message and reply with real console output. Edit the browser example on StackBlitz without an account.
For native execution, run the Expo / Hermes example. It includes SQLCipher configuration and an identity check after restart. Use a native development or release build. Expo Go does not include SQLCipher.
Install locally
npm install @open-e2ee/signal-protocol-sdk@7.1.0The whole round trip
Two identities in one process, a development relay between them.
import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { inMemoryStore } from '@open-e2ee/signal-protocol-sdk/local/store/memory';
import { inMemoryRelay } from '@open-e2ee/signal-protocol-sdk/remote/relay/memory';
// One in-memory relay, shared by both identities.
const relay = inMemoryRelay();
await relay.registerDevice('alice', { encryptedDeviceName: new ArrayBuffer(0) });
await relay.registerDevice('bob', { encryptedDeviceName: new ArrayBuffer(0) });
const alice = await createSignalProtocolClient({
identity: { userId: 'alice' },
adapters: { storage: inMemoryStore(), relay },
});
const bob = await createSignalProtocolClient({
identity: { userId: 'bob' },
adapters: { storage: inMemoryStore(), relay },
});
const delivered = new Promise((resolve) => {
bob.registerHook('onMessageDecrypted', async (message) => {
console.log(`${message.senderId}: ${message.content}`);
bob.stopRelaySubscription();
resolve(undefined);
});
});
await alice.send('bob', 'the ratchet turned');
bob.startRelaySubscription();
await delivered;That prints alice: the ratchet turned.
The protocol and cryptography are real. The example simulates infrastructure in memory. send() ran the required PQXDH handshake against Bob's published prekey bundle, created the configured ratchets, and produced real ciphertext. The in-memory relay is a development stand-in for the service that registers devices, distributes public keys, and carries envelopes.
What each line actually did
| Call | What happened |
|---|---|
inMemoryStore() | Created the device-local protocol state. adapters.storage is the only required adapter. |
inMemoryRelay() | Created an in-memory stand-in for the service that distributes public keys and carries envelopes. |
registerDevice() | Registered each development device with the relay. A production relay must authenticate and authorize this step. |
createSignalProtocolClient | Created or loaded the device's identity, prekeys, sessions, and registration state. Because a relay was supplied, the factory also published the public prekey bundle. |
registerHook('onMessageDecrypted', …) | Registered the callback that receives a DecryptedEnvelope after successful decryption. |
send() | Fetched Bob's prekey bundle, established a session on first contact, encrypted, and handed the envelope to the relay. |
startRelaySubscription() | Started relay delivery and device-local decryption. The promise keeps the example alive until the hook receives the plaintext. |
Prove the middle is opaque
The claim worth verifying yourself is that the thing in transit is not the thing you sent. Encrypt without a relay and look at the bytes:
const result = await alice.send('bob', 'the ratchet turned');
console.log(result);send() returns a SendResult. What crosses the wire is a ciphertext plus routing metadata: sender and recipient identifiers, device IDs, a message type, and a timestamp. The relay needs those to deliver. It does not need, and does not get, the plaintext.
That distinction is the whole product, and it has a hard edge worth understanding early: the metadata is real and it is not encrypted. Who talks to whom, how often, from how many devices, and at what size is visible to whoever runs the relay. See what the relay can still see before you make promises to your users.
Replace the in-memory store
The in-memory adapters were the shortcut. Replacing them is the actual integration work, and it differs by runtime more than anything else in the SDK. Pick yours: the rest of the documentation will keep showing it.
npm install @open-e2ee/signal-protocol-sdk@7.1.0 expo-crypto expo-secure-store expo-sqlite drizzle-ormimport { expoStore } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
const signal = await createSignalProtocolClient({
identity: { userId },
adapters: { storage: expoStore(), relay },
});expoStore() is the one synchronous store because your application opens its database before the client exists. That bootstrap uses a SQLCipher database with a key from expo-secure-store. It needs a development build, so understand it before you start.
npm install @open-e2ee/signal-protocol-sdk@7.1.0import { indexedDbStore } from '@open-e2ee/signal-protocol-sdk/local/store/web';
const storage = await indexedDbStore();The adapter has no peer dependencies. It uses IndexedDB and Web Crypto, which browsers provide. It supports the full store contract. A deployment still requires the browser threat-model review.
npm install @open-e2ee/signal-protocol-sdk@7.1.0import { nodeStore } from '@open-e2ee/signal-protocol-sdk/local/store/node';
const storage = await nodeStore({ dataDir: './.signal-protocol' });Set dataDir explicitly. The default is under the user's home directory. An inherited path can give a service an unintended second identity.
The relay is the other half. It is a backend choice, not a runtime choice. Choosing adapters describes all four slots and their maturity.
The seven production decisions behind this example
The short program works because the in-memory adapters hide decisions that a production application must make. Decide these before you ship.
-
Package.
@open-e2ee/signal-protocol-sdkis OpenE2EE's maintained TypeScript package for this protocol profile. It is independent and does not interoperate with Signal Messenger or libsignal. Compare the maintained alternatives and their limits. -
Key custody. Identity private keys, prekey private halves, sessions, and ratchet state stay in the device-local protocol store. The relay receives public key material. The in-memory store has no wrapping key. The Expo store holds a SQLCipher key through secure storage. The browser store keeps its record key in the same origin. The Node store protects encrypted records with local filesystem controls.
-
Key lifetime. Identity keys change only through explicit trust, while signed and Kyber prekeys refresh after two days. Their hard age ceiling is 14 days. One-time prekeys live until a peer consumes them. Retained Double Ratchet message keys expire after seven days. Deleting local storage removes only this device's copy. It cannot erase copies, plaintext, or backups held elsewhere.
-
Relay visibility. Per message, the relay receives ciphertext and delivery metadata. This includes user and device identifiers, message type, timestamp, and size. It does not need message plaintext or device private keys. Encryption is not anonymity. Read limits and metadata before you write user-facing claims.
-
Prekey maintenance.
syncToServer()uploads and replenishes batches of EC and Kyber one-time public prekeys. Mobile apps can run it on foreground, while services can use a schedule. Exhaustion can use a last-resort key with weaker initial forward secrecy. The sender receives no error for that fallback. Monitor the remaining count and the low-watermark hook. Key rotation gives the exact controls. -
Additional devices. Provisioning shares the account identity with the new device. The device gets its own registration, prekeys, and sessions. It does not receive existing sessions or message history. It cannot decrypt messages sent before it existed. Device lifecycle covers provisioning and revocation.
-
Recovery. The SDK does not choose a recovery policy. A
business-archiveprofile keeps an encrypted recovery artifact for continuity. This creates a durable attack surface. Ahigh-risk-messagingprofile makes lost-device history unrecoverable. Acollaboration-defaultprofile preserves limited working context but is not a system of record. Recovery, backup, and migration defines the tradeoffs and operations.
Authentication, authorization, first-contact trust, safety-number verification, groups, and attachments are also application work. Use the production checklist before release.
Next
- Choosing adapters: decide what replaces the two in-memory adapters.
- End-to-end encryption architecture: the model underneath the six lines.
- Keys, identity, and sessions: what
syncToServer()published and why. - API reference: the full client surface.