OpenE2EE

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.0

The 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

CallWhat 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.
createSignalProtocolClientGenerated 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-orm
import { 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.

Expo setup →

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

On this page