OpenE2EE

Expo

Set up the Expo storage adapter and secure-store vault, and get direct answers on key storage, retention, prekeys, reinstall, and backups.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo (development build) · React Native
Prereqs
Quickstart, plus an Expo project you can make a development build of
Reading time
12 min

Expo is the primary runtime for this SDK. Other options do not support a complete Signal Protocol implementation in React Native. Hermes ships without WebCrypto, and expo-crypto does not support key agreement. Native modules either fail in Expo Go or conflict with the encrypted database libraries that an E2EE app needs. This implementation uses pure TypeScript, so the cryptography needs no native module or prebuild step.

This requires a development build

The Expo store keeps protocol state in an application-owned SQLCipher database. The SDK documentation is explicit: "SQLCipher requires a development build and is not available in Expo Go." Plan to use npx expo run:ios, run:android, or an EAS development build from the start. This requirement is the most common surprise on this page.

Install

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

Two storage boundaries, never blurred

The Expo setup has two distinct places secrets live, and conflating them is the most consequential mistake you can make here.

HoldsSizeFacility
VaultOne database encryption key32 bytesexpo-secure-store → iOS Keychain / Android Keystore
StoreIdentity keys, prekeys, sessions, ratchet state, contact trust, message recordsKilobytes to megabytesSQLCipher-encrypted SQLite

Platform secret managers hold tiny bootstrap values, not a session database that changes on every message. So the vault holds exactly one thing, the key that unlocks the database, and the database holds everything else.

Bootstrap

The application owns database creation, so it can compose the SDK's tables into its own schema and transaction lifecycle. Do this once at startup, before creating the client.

import { configureSignalProtocolExpoDbBindings } from '@open-e2ee/signal-protocol-sdk/local/store/expo/db';
import { getDatabaseKeyManager } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
import * as signalSchema from '@open-e2ee/signal-protocol-sdk/local/store/expo/schema';

const keyManager = getDatabaseKeyManager();
await keyManager.initialize();
const sqlCipherPassword = await keyManager.getPassword();

// Application-owned. Must enable SQLCipher through the expo-sqlite native
// configuration, apply the key before any schema access, create or migrate the
// exported tables, and return matching raw and Drizzle handles.
const { rawDatabase, drizzleDatabase } = await appDatabase.openEncryptedDatabase({
  password: sqlCipherPassword,
  schema: signalSchema,
});

configureSignalProtocolExpoDbBindings({
  getDrizzle: async () => drizzleDatabase,
  getRawDatabase: () => rawDatabase,
});

Then the client:

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

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

await signal.syncToServer();

signal.registerHook('onMessageDecrypted', (envelope) => {
  appendToConversation(envelope.conversationId, envelope.content);
});

signal.startRelaySubscription();

expoStore() is synchronous and takes an optional relay and logger. Configure bindings first because the store reads them lazily on its first query.


The seven questions

These come up in every integration. Direct answers, from the SDK's actual behaviour, with the boundary marked wherever the SDK leaves the decision to you.

1. Where are the keys stored?

The database key is a random 32-byte AES-256 key held under the identifier signal_db_encryption_key by ExpoSecureStoreSignalProtocolSecretVault, which is a thin wrapper over expo-secure-store. That resolves to the iOS Keychain and the Android Keystore-backed secure storage.

The code sets the accessibility class deliberately:

keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY

AFTER_FIRST_UNLOCK lets background message delivery and recovery work during a screen lock. THIS_DEVICE_ONLY excludes the value from encrypted device backups and transfer to a new device. This exclusion is a security property, and it also explains the answer to question 5.

The SQLCipher database contains all other protocol data. This data includes identity keys, prekeys, sessions, ratchet state, contact trust decisions, and message records.

2. How long are they retained?

Different lifetimes for different material, and none of it is on a session timer:

MaterialRetention
Identity key pairIndefinite. It is the identity; rotating it is an identity change your contacts will see.
Signed prekey / Kyber prekeyRotated on keyRefreshIntervalMs, default 1728000002 days. Old keys are kept briefly so in-flight messages still decrypt.
One-time prekeysUntil consumed by an incoming session, or until maxPreKeyAgeMs (14 days) makes them replaceable.
Session and ratchet stateIndefinite, until the session is archived or deleted.
Skipped message keysmaxMessageKeysStored 1000 per chain, and keyExpirationMs 7 days.
Unacknowledged sessionsMAX_UNACKNOWLEDGED_SESSION_AGE_MS, 30 days.

Ignore the stale weekly comment

Two JSDoc comments in the SDK still say to rotate signed prekeys "weekly". They are stale, and the TypeDoc generator carries that stale text into its output. The keyRefreshIntervalMs default of 172800000, two days, is authoritative.

3. How do I keep keys away from my own backend?

The SDK does not contain a code path that sends a private key. syncToServer() uploads only public material. This material includes the identity public key, signed prekey and signature, Kyber prekeys, and the one-time prekey batch. The SDK writes the private keys to the local store and reads them from that store.

The realistic leak is not the SDK, it is your app. Named concretely, because this is the question people actually ask about Supabase, Firebase, and every analytics SDK in a React Native project:

  • Do not put protocol state in a synced table. Do not add a key column to a synced profiles or devices row. The relay handles public keys and envelopes. The ISignalProtocolRelayServer interface does not accept private material.
  • Do not log envelopes or store handles. Crash reporters and session-replay tools serialise objects aggressively. Add protocol objects to the redaction list.
  • Avoid syncing filesystems. A provider could receive both the encrypted database and its escaped keychain value.
  • Do not send plaintext to your backend "for search". Server-side message search can break end-to-end encryption. Build the index on the device.

4. How many prekeys, and when do they refill?

You do not choose the batch size. The SDK generates 100 one-time prekeys at registration and on replenishment. The numbers that matter to you:

SettingDefaultMeaning
One-time prekey batch100Generated per replenishment round.
preKeyLowThreshold50When checkPreKeyStatus() reports needsReplenishment and onPreKeyLow fires.
Internal replenishment threshold10The hard floor the client replenishes at on its own.
preKeyCheckThrottleMs12 hoursHow often a check will actually run.

Wire it to something that runs when the app foregrounds:

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

5. What happens when the user reinstalls the app?

They lose everything, and this is not recoverable by the SDK.

Uninstalling removes the app's SQLCipher database. The vault entry is THIS_DEVICE_ONLY and excluded from backups. On iOS, keychain items can sometimes survive an app uninstall. Do not depend on this behavior because a surviving key cannot decrypt a deleted database.

So after a reinstall the user is, cryptographically, a new device:

  • New identity key pair, new registration ID, new prekeys.
  • Every existing session is dead. Message history encrypted locally is gone.
  • Every existing contact sees an identity change. The next message produces IdentityKeyChangedError or a safety-number change in the UI.
  • The new identity cannot decrypt messages that peers sent to the old identity while it was still current.

This is the correct behaviour for a protocol with forward secrecy and no key escrow, and it is also the moment your support burden appears. Decide the product response deliberately: recovery, backup, and migration sets out three named profiles with different tradeoffs and no default. Whatever you pick, handle the contact-side experience in identity change and safety numbers rather than letting a raw error reach the UI.

6. What does the relay store?

Public material and opaque envelopes. Nothing that decrypts anything.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair (private)yesneverneverno
Identity public keyyesyesnoyes — it is the pinned identity
Signed prekey + signatureyesyes (public)noyes
Kyber prekeysyes (private)yes (public)noyes
One-time prekeysyes (private)yes (public)nocount and consumption are observable
Session / ratchet stateyesneverneverno
Message plaintextyesneverneverno
Message ciphertextyesyesnosize and timing
senderUserId, targetUserId, device IDsyesyesnoyes — the social graph
timestamp, messageType, clientMessageIdyesyesnoyes
Attachment bytesencrypted firstnoyes, opaquesize, count, timing
Attachment media keyyesin the encrypted messageneverno

The database key and the SQLCipher database never appear in the "sent to relay" column under any configuration. There is no mode that changes that.

7. What should I do about backups?

The SDK does not include a backup mechanism, and the keychain entry is deliberately excluded from device backups. Without a backup, an uninstall or lost phone means lost history and a new identity. This product choice is defensible and resembles the Signal Messenger approach, but you must make it deliberately.

The three positions, all legitimate, all with something given up:

  • No backup. Strongest confidentiality, worst user experience on device loss. Users will lose history and blame you.
  • User-held key backup. The user holds a recovery secret that you cannot reproduce. History restores if they kept it. Signal Messenger uses a 64-character recovery key that "Signal cannot recover, reset, or bypass" and therefore protects the key at the cost of more complex support.
  • Provider-assisted escrow. This option gives the best recovery experience. You then hold data that can decrypt user content. That responsibility changes your threat model, compliance surface, and product claims.

Device-to-device transfer is different from backup. It moves cryptographic state directly between two user-controlled devices, with no third-party storage. Linked-device provisioning transfers identity but does not transfer sessions or history. See multi-device and device lifecycle.

Full treatment, with three named profiles you can adopt as-is: recovery, backup, and migration.


Production caveats

The SDK is 0.1.x. Public APIs and persisted formats may change before 1.0. It is reviewed continuously by adversarial AI agents; it is not audited by any independent firm. Verify behaviour on both iOS and Android release builds, not just in development: secure-storage and background-delivery behaviour differ.

Next

On this page