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() },
});The four adapters, with their real status
npm install @open-e2ee/signal-protocol-sdk@0.1.0 expo-secure-store expo-sqlite| Adapter | Subpath | Exports | Status |
|---|---|---|---|
| Expo | /local/store/expo | ExpoSignalProtocolStore, expoStore() | Primary supported adapter |
| Node | /local/store/node | NodeSignalProtocolStore, nodeStore() | Supported, filesystem-backed |
| Browser | /local/store/web | IndexedDbSignalProtocolStore, indexedDbStore() | Supported; requires the browser threat-model review |
| React Native (bare) | /local/store/react-native | ReactNativeSignalProtocolStore, reactNativeStore() | Supported; you supply and verify the key-value backend |
| In-memory | /local/store/memory | InMemorySignalProtocolStore, 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
| Artifact | Local owner | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|---|
identityKeyPair private half | store | yes | no | no | no |
identityKeyPair public half | store | yes | yes | no | yes |
SessionRecord.currentSession | store | yes | no | no | no |
SessionRecord.archivedSessions | store | yes | no | no | no |
SessionRecord.version | store | yes | no | no | no |
| EC one-time prekey private halves | store | yes | no | no | no |
| EC one-time prekey public halves | store | yes | yes | no | yes |
| Signed prekey private half | store | yes | no | no | no |
| Kyber prekey private halves | store | yes | no | no | no |
| Contact identity trust / TOFU decisions | store | yes | no | no | no |
Sender-key state (hasGroupSenderKey) | store | yes | no | no | no |
| Retry message records | store | yes | no | no | no |
"signal-store-wrapping-key" | vault | yes | no | no | no |
Message plaintext after onMessageDecrypted | your app | yes | no | no | no |
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
- Choosing adapters: select a store for your runtime
- Device registration and lifecycle: clear the store, vault, and device ID as one reset
- Error handling: use the complete
EncryptionErrorCodetriage path - Recovery, backup, and migration: move a store to new hardware
Device registration and lifecycle
How a user becomes a set of devices, who allocates device IDs, and what registration, provisioning, unlink, and account reset each own.
Relay and prekey infrastructure
What a relay must do, what it never needs, and the exact prekey batch sizes, thresholds, and rotation intervals the SDK uses.