Quickstart
Install the SDK and get a real encrypted round trip between two identities, with no backend and no accounts.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Node · Browser · Expo
- Prereqs
- Node 20+ 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. No server, no accounts, no signup. Once that runs, you know the protocol works in your runtime. Every other page explains how to replace the pieces.
npm install @open-e2ee/signal-protocol-sdk@0.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();
const alice = await createSignalProtocolClient({
identity: { userId: 'alice' },
adapters: { storage: inMemoryStore(), relay },
});
const bob = await createSignalProtocolClient({
identity: { userId: 'bob' },
adapters: { storage: inMemoryStore(), relay },
});
// Publish each identity's public keys and prekeys so the other can start a session.
await alice.syncToServer();
await bob.syncToServer();
bob.registerHook('onMessageDecrypted', (envelope) => {
console.log(`${envelope.senderId}: ${envelope.content}`);
});
await alice.send('bob', 'the ratchet turned');
await bob.processIncomingEnvelopes();That prints alice: the ratchet turned.
Nothing above is a simulation. send() ran a real X3DH/PQXDH handshake against the prekey bundle bob.syncToServer() published, derived a Double Ratchet chain, and produced a real ciphertext. The relay is a stand-in. The cryptography is not.
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. |
createSignalProtocolClient | Generated a long-term identity key pair, a registration ID, signed prekeys, and a batch of 100 one-time prekeys. |
syncToServer() | Uploaded the public halves — identity key, signed prekey, Kyber prekeys, one-time prekeys. Private keys never leave storage. |
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. |
processIncomingEnvelopes() | Pulled pending envelopes and decrypted them. In a real app you call startRelaySubscription() once instead and let delivery push. |
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@0.1.0 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 }), 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@0.1.0import { indexedDbStore } from '@open-e2ee/signal-protocol-sdk/local/store/web';
const storage = await indexedDbStore();No peer dependencies: the adapter uses IndexedDB and Web Crypto, both built in. It is a supported adapter with the full store contract; deployment requires the browser threat-model review.
npm install @open-e2ee/signal-protocol-sdk@0.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.
What this quickstart deliberately skipped
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.