OpenE2EE

Local encrypted storage

Why the storage adapter is required, what the four adapters actually support, and why the vault and the store must never be blurred.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node · React Native
Prereqs
A working client from Start → Quickstart
Reading time
11 min

You must configure adapters.storage. You can omit adapters.relay. This asymmetry defines the SDK architecture.

A relay provides delivery, and you can replace it. The local test path encrypts and decrypts without a relay. You cannot replace the local store because it holds the ratchet. It also holds identity keys, contact trust decisions, prekeys, session records, and retry message records. If you lose this store, no server copy can recover it.

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() },
});
A device outline with its encrypted local store attached to the device edge.deviceISignalProtocolLocalStoreidentityKeyPair
The store shares an edge with the device. It belongs to that hardware and is not a reachable service.

The four adapters, with their real status

npm install @open-e2ee/signal-protocol-sdk@0.1.0 expo-secure-store expo-sqlite
AdapterSubpathExportsStatus
Expo/local/store/expoExpoSignalProtocolStore, expoStore()Primary supported adapter
Node/local/store/nodeNodeSignalProtocolStore, nodeStore()Supported, filesystem-backed
Browser/local/store/webIndexedDbSignalProtocolStore, indexedDbStore()Supported; requires the browser threat-model review
React Native (bare)/local/store/react-nativeReactNativeSignalProtocolStore, reactNativeStore()Supported; you supply and verify the key-value backend
In-memory/local/store/memoryInMemorySignalProtocolStore, inMemoryStore()Development only

Expo is the primary supported adapter and has the most deployment evidence. It requires a development build and is not available in Expo Go. The /local/store/expo subpath also exports getKeyStorage, getDatabaseKeyManager, clearDatabaseKeyCache, and createPreKeyMaintenanceStore.

Browser and React Native (bare) implement the full core store contract. This contract includes SESAME records, sender-key state, retry message records, and recovery helpers. Both are supported, and each one's graduation gates run on every change to the source repository. For the browser adapter the gates are contract suites in real Chromium, Firefox, and WebKit, multi-tab, interruption, storage-pressure, and soak, and deployment requires the origin-security review in browser setup. For the bare React Native adapter the gates are the exported backend-conformance kit and a CI run of the SDK's reference backend on the Hermes engine, with interruption and storage-pressure cases. The store's durability is only as good as the key-value backend you supply, so run the exported kit, assertBackendConformance, against your backend from your own tests:

import { ReactNativeSignalProtocolStore } from '@open-e2ee/signal-protocol-sdk/local/store/react-native';

const storage = await ReactNativeSignalProtocolStore.create({ storage: yourKeyValueBackend });

Node uses the filesystem and supports test harnesses, service-side clients, and CLI tools. In-memory keeps state in memory for development only. It discards all data at process exit. Use it for the two-client Quickstart sample, but not for user data.

Adapter selection affects more than convenience. See Choosing adapters.

The SDK versions session records and resets old records

The persisted session record is version 4:

interface SessionRecord {
  currentSession: SessionState | null;
  archivedSessions: Record<string, SessionState>;
  version: 4;
  metadata?: SessionRecordMetadata;
}

Version 4 binds each endpoint's composite identity and explicit identity type to the live session. The SDK rejects and resets older formats. It does not migrate them.

When the SDK encounters an earlier format, it does not reconstruct unverifiable ratchet state. It discards the session. The next message to that peer creates a new session from a fresh prekey bundle. Users do not receive an error. They can see a conversation safety-number change. They also cannot decrypt in-flight ciphertext that used the discarded session.

This conservative choice avoids a later, silent ratchet failure caused by an incorrect migration. Plan upgrades for this behavior. The SDK is pre-1.0: 0.1.x; public APIs and persisted formats may change before 1.0. Upgrades covers the rollout mechanics.

Atomicity is a security property, not a performance one

A decrypt operation changes ratchet state. The store advances the chain key, derives and consumes message keys, and writes the record. It must complete this write before it gives plaintext to your application. Two concurrent decrypt operations or a partial write can corrupt that state. No server copy exists for reconciliation.

The store contract therefore defines two atomic boundaries. One transaction commits contact trust, session creation or advancement, and responder one-time-prekey consumption. Another transaction accepts identity rotation and deletes every bound device session. An adapter must not publish only part of either transition.

For bare React Native, the backend's atomicWrite is a security boundary, not a batching optimization. It must commit check, set, remove, and removeSessionsForUser in one crash-durable transaction, and the final operation must enumerate exact plaintext session metadata inside that transaction. Enumerating before the transaction can leave a concurrently created session trusted under a rotated identity.

The vault is not the store

These are two different things and blurring them is the most common architectural mistake on this page.

The store owns the protocol database, including each session, prekey, and trust decision. The vault owns small bootstrap secrets through getSecret(key), setSecret(key, value), and deleteSecret(key).

import { ExpoSecureStoreSignalProtocolSecretVault } from '@open-e2ee/signal-protocol-sdk/local/vault/expo-secure-store';

const vault = new ExpoSecureStoreSignalProtocolSecretVault();

await vault.setSecret('signal-store-wrapping-key', wrappingKey);
const restored = await vault.getSecret('signal-store-wrapping-key');

The SDK states the reason directly:

"platform secret managers are appropriate for tiny keys and bootstrap values, but not full session databases."

Keychain and Keystore entries have size limits, latency characteristics, and access-prompt behavior. A session-record database cannot tolerate these constraints. The vault therefore holds a wrapping key. The wrapping key protects the store, and the store holds the session database.

Vault secret names are application-wide storage keys. Add a namespace to prevent collisions with other application data. Delete the secrets and encrypted store in the same account-reset transaction. See Device registration and lifecycle. Your application still controls platform backup, biometric access, and device-migration behavior.

Opacity ledger

ArtifactLocal ownerStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair private halfstoreyesnonono
identityKeyPair public halfstoreyesyesnoyes
SessionRecord.currentSessionstoreyesnonono
SessionRecord.archivedSessionsstoreyesnonono
SessionRecord.versionstoreyesnonono
EC one-time prekey private halvesstoreyesnonono
EC one-time prekey public halvesstoreyesyesnoyes
Signed prekey private halfstoreyesnonono
Kyber prekey private halvesstoreyesnonono
Contact identity trust / TOFU decisionsstoreyesnonono
Sender-key state (hasGroupSenderKey)storeyesnonono
Retry message recordsstoreyesnonono
"signal-store-wrapping-key"vaultyesnonono
Message plaintext after onMessageDecryptedyour appyesnonono

The last row is the one to read twice. Your app owns decrypted message rows and local files after the Signal Protocol client decrypts or stages them. The store does not keep your message history for you.

Next

On this page