Design offline, device recovery, and identity-change behaviour safely
A design guide for the three product decisions that end-to-end encryption forces on you, with the SDK's real bounds, errors, and operations attached to each.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client that already sends and receives messages
- Reading time
- 18 min
This is a design guide, not a copy-paste build. The code here is real and verified, but you cannot finish this page by pasting it. Every section ends in a decision that belongs to your product, and the SDK deliberately does not make it for you.
Three questions meet here:
- What happens to a message composed on a train?
- What happens when the phone holding the keys goes into a canal?
- What happens when a contact's identity key changes, which it will, routinely?
Teams often accept defaults instead of making decisions. Examples include an infinite outbox, no recovery path, and a banner for every key change. Each default has costs.
Intended audience
This guide is for engineers and product owners who make architecture decisions before launch. Their product has working messages but has not yet faced device loss.
Prerequisites
- A working client from Quickstart with a
storageadapter and a relay configured. - Keys, identity, and sessions, read first: part three assumes you know what a session and a composite identity are.
- A threat model you can state in a sentence. See Threat model. Every decision below is undecidable without one.
Install
npm install @open-e2ee/signal-protocol-sdk@0.1.0On Expo, the storage adapter and the bootstrap vault need peer packages:
npm install @open-e2ee/signal-protocol-sdk@0.1.0 expo-secure-store expo-sqliteThe Expo store requires a development build and is not available in Expo Go.
Part one: offline
What the SDK bounds, exactly
The Double Ratchet handles gaps by deriving and storing the message keys it skipped, so a late message still decrypts. The bounds are not infinite:
| Bound | Value | What it means when you hit it |
|---|---|---|
maxSkip | 1000 | A message more than 1000 ahead of the last received counter is refused |
maxMessageKeysStored | 1000 | Only 1000 skipped keys are retained at once |
keyExpirationMs | 604800000 (7 days) | A skipped key older than a week is dropped |
MAX_UNACKNOWLEDGED_SESSION_AGE_MS | 30 days | A prekey session that never got a reply is stale past this |
These four numbers are your offline window. A device back after eight days gets
MESSAGE_TOO_OLD. One that missed more than 1000 messages in a chain gets
TOO_MANY_SKIPPED_MESSAGES. Retrying the same ciphertext cannot recover either case. The
receiving device never derived the key material, or it deleted that material.
MESSAGE_DUPLICATE and DuplicatedMessageError describe the opposite case. The ratchet
already consumed the message number, as expected for redelivery or post-crash replay.
The outbox is your job
There is no send queue in the SDK. send(recipientUserId, content) attempts the operation
now and throws if it cannot complete. What you get instead is a bounded retry primitive:
import { withRetry } from '@open-e2ee/signal-protocol-sdk/utils/retry';
await withRetry(() => client.send(recipientUserId, body), {
operationName: 'send',
maxRetries: 2,
baseDelay: 1000,
maxDelay: 10000,
});Those are the defaults: maxRetries 2 (three attempts total), baseDelay 1000 ms,
maxDelay 10000 ms, jitter on. withRetry consults isRetryableError(), which refuses a
fixed non-retryable set: INVALID_PREKEY_BUNDLE, SESSION_CONFLICT, ENCRYPTION_FAILED,
TOO_MANY_SKIPPED_MESSAGES, IDENTITY_KEY_CHANGED. Retrying an identity change three
times produces three identical failures and a slower error.
withRetry covers one operation crossing a flaky network. It is not durable and does not
survive a process restart. Your outbox rows live in your database.
Catching up
client.startRelaySubscription();
client.startRetryRequestSubscription();
const results = await client.processIncomingEnvelopes(pendingEnvelopes);
for (const result of results) {
if ('plaintext' in result) {
await db.insertMessage(result.envelope, result.plaintext);
} else {
await db.recordUndecryptable(result.envelope, result.error);
}
}processIncomingEnvelopes() returns one result for each envelope instead of stopping at
the first failure.
It processes prekey messages before ordinary ciphertexts. A session can therefore exist before the messages that depend on it arrive.
startRetryRequestSubscription() handles the SESAME retry path for peers who could not
decrypt something you sent.
Part two: recovery
Device loss is certain over a long product lifetime. It might first appear as a support ticket months after you set the architecture. Decide the recovery policy before launch. Recovery, backup, and migration defines three named profiles. Pick one explicitly and write the name down.
business-archive: history survives device loss, staff turnover, and hardware refresh. This profile gives up coercion resistance. An authority can compel a durable recovery path. A social engineer can exploit it, or an insider can misuse it. Suits regulated professional services.high-risk-messaging: a lost device means lost history, and that is the feature. Gives up continuity, and becomes your most common support category. Suits journalism and source protection.collaboration-default: an explicit compromise. Recovers recent working context within a stated retention horizon. Gives up being a system of record, and coercion resistance. Suits team tools where another system holds the durable record.
None is the correct default. If a product omits the choice, a backup selects business-archive
by accident. Without a backup, the product selects high-risk-messaging by accident.
Why this is structurally hard
1Password states the structural problem directly:
"Recovery mechanisms are inherently weak points in maintaining the secrecy of data."
A recovery path for a user who lost everything can also become a path for another person.
Signal Messenger uses a 64-character recovery key that "Signal cannot recover, reset, or bypass". If the user loses that key, Signal cannot restore it. Matthew Green describes the risk of the alternative:
"Nobody is going to engineer something as complex as Signal's SVR just to store contact lists. Once you have a hammer like SVR, you're going to want to use it to knock down other nails."
What the SDK actually moves
import {
prepareNewDeviceTransfer,
prepareOldDeviceTransferWithBackup,
} from '@open-e2ee/signal-protocol-sdk/device';
const receiving = await prepareNewDeviceTransfer();
await appQr.show(receiving.qrCode);
const sending = await prepareOldDeviceTransferWithBackup(backupStorage);
const backup = await sending.getBackup(sessionIds);Transfer migrates local cryptographic state, identity keys, prekeys, sessions, and requires the old device. It is an upgrade story. Provisioning a linked device transfers identity material and optional metadata, and not sessions or history. The phone in the canal participates in neither. Recovery: restoring to a device that never held the state: is a system your application builds.
clearAllData() handles only the local half of an account reset. A complete reset must
clear the device-ID cache, platform secret storage, and protocol store in one lifecycle.
The SDK source limits forceCompleteKeyReset() to development and debugging.
Part three: identity change
The three errors
import {
isIdentityKeyChangedError,
isUntrustedIdentityError,
isRegistrationIdChangedError,
} from '@open-e2ee/signal-protocol-sdk/types';
try {
await client.send(remoteUserId, body);
} catch (error) {
if (isIdentityKeyChangedError(error)) {
// error.changedAddress, error.oldIdentityKey, error.newIdentityKey
await auditLog.recordIdentityChange(error);
return;
}
if (isUntrustedIdentityError(error)) {
return; // fails closed until the identity is accepted
}
if (isRegistrationIdChangedError(error)) {
// error.resetAddress — the peer's device registration changed underneath the session
return;
}
throw error;
}IdentityKeyChangedError (IDENTITY_KEY_CHANGED) is the classic "safety number changed"
event and carries .changedAddress, .oldIdentityKey, .newIdentityKey.
UntrustedIdentityError (UNTRUSTED_IDENTITY) means the identity is not accepted, so the
operation fails closed. RegistrationIdChangedError (REGISTRATION_ID_CHANGED) carries
.resetAddress.
Accepting a change is explicit, and is not the same as verifying it:
await client.acceptIdentityRotation(remoteUserId, newCompositeIdentity);trustIdentity() does not exist, despite appearing in a stale SDK document.
Use acceptIdentityRotation(userId, identity, identityType?). It resets sessions bound to
the old tuple and returns the contact's identity record. It does not mark the contact
VERIFIED.
Two different safety-number shapes
These are separate objects. Do not merge them.
const safetyNumber = await client.verify(remoteUserId);
// { numeric, fingerprint, userId, identityType, trustState, confirmation }
showVerificationDialog(safetyNumber);
await client.confirmSafetyNumber(safetyNumber.confirmation);client.verify() returns comparison data and an immutable confirmation token for the
displayed tuple. A key that changes between rendering and confirmation cannot pass by
accident. It does not return emojis or scannable. Use the standalone module for
all representations:
import { generateCompositeSafetyNumber } from '@open-e2ee/signal-protocol-sdk/safety';
const composite = generateCompositeSafetyNumber(
localCompositeIdentity,
remoteCompositeIdentity,
localUserId,
remoteUserId,
);
// { numeric, emojis, hex, qrData, scannable }
if (composite.scannable.compare(scannedQrBytes) === 'match') {
await recordUserDecision(remoteUserId);
}IdentityTrustState is 'UNVERIFIED_TOFU' | 'VERIFIED'. There is no third state, and
verification is a statement about one tuple at one moment, not a permanent property of a
contact.
The part the research settles
Users cannot do this. In one study, 21 of 28 computer science students could not verify a public key. In another, which explained the risks first, only 13% completed the ceremony. People primed to care, and most did not finish.
Keybase described the burden directly:
"Checking is infeasible, since it happens way too often. Checking sucks."
It proposed TADA, Trust After Device Additions, instead of trust on first use. Users must repeat the decision after a phone upgrade, reinstall, or tablet link.
Warning fatigue is therefore the default outcome of bannering every change, not a possible risk.
IdentityKeyChangedError tells the truth. A high-base-rate interrupt creates the failure.
The industry increasingly makes key changes auditable instead of asking users to judge each one. Apple's Contact Key Verification and WhatsApp's Automatic Device Verification check continuity in the background. This SDK provides comparison data, but no transparency log or automated continuity check.
Trust boundaries: what crosses which line
Device to relay. Sealed envelopes cross this boundary. Public identity keys and prekeys also cross because peers must fetch them. The relay does not receive plaintext or private keys. Therefore, it cannot resolve offline conflicts, restore a lost device, or classify an identity change.
Device to device. Transfer moves cryptographic state directly. Ephemeral ECDH keys derive the encryption keys. The device encrypts identity and backup material before it reaches a transport. Provisioning sessions expire after five minutes.
Device to your application's storage. Your app owns decrypted message rows, local files, outbox rows, audit records, and trust decisions. Nothing in the SDK reaches in.
The line that does not exist. No boundary here lets a server resolve any of the three problems on this page. That absence is the design, and every decision above is downstream of it.
What the backend can see
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
| Application outbox rows | yes | no | n/a | no |
| Envelope ciphertext | yes | yes | n/a | size, routing, arrival time |
Skipped message keys (maxMessageKeysStored) | yes | no | n/a | no |
Session record (version: 4) | yes | no | n/a | no |
client.syncStatus | yes | no | n/a | no |
identityKeyPair private half | yes | no | n/a | no |
identityKeyPair public half | yes | yes | n/a | yes |
| Prekeys (private halves) | yes | no | n/a | no |
| Prekeys (public halves) | yes | yes | n/a | count and consumption rate |
Backup from sending.getBackup(sessionIds) | yes | encrypted, if routed that way | optional, encrypted | size and write time |
| Backup encryption key | yes, or user-held | no | n/a | no |
safetyNumber.numeric | yes | no | n/a | no |
safetyNumber.confirmation | yes | no | n/a | no |
Trust state (UNVERIFIED_TOFU / VERIFIED) | yes | no | n/a | no |
IdentityKeyChangedError.newIdentityKey | yes | already public | n/a | yes |
| Your audit record of identity changes | yes | no | n/a | no |
| Encrypted device name | yes | yes, encrypted | n/a | that a device exists |
Teams often miss the public prekey row. The relay observes consumption rate. A 2025
WhatsApp study found that 13% of companion devices lacked a one-time prekey during the
scan. Exhaustion weakens forward secrecy without an error.
checkPreKeyStatus() returns { oneTimePreKeysRemaining, needsReplenishment }, and the
onPreKeyLow callback fires against the preKeyLowThreshold default of 50.
Failure and recovery behaviour
Production caveats
0.1.x; public APIs and persisted formats may change before 1.0. Persisted formats
matter here: a backup written by an older build may contain session records the current
build rejects and resets rather than migrates.
The SDK is reviewed continuously by adversarial AI agents; it is not audited by any
independent firm. The Expo store is the primary supported adapter and needs a
development build. On the bare React Native store, durability is a property of
the key-value backend you supply — verify it with the exported
backend-conformance kit, with the reopen hook wired. The in-memory store and
in-memory relay are development only: recovery testing
built on them proves nothing about durability. forceCompleteKeyReset() is development
and debugging only. Do not wire it to a user-facing "reset my account" button.
Next
- Offline send, receive, and reconciliation: the SDK-level detail behind part one
- Recovery, backup, and migration: the three profiles in full
- Identity changes and safety numbers: the verification build
- Error handling: the full error taxonomy
Add encrypted attachments with an opaque object store
A complete build for encrypted file transfer through a brokered object store, using either the Convex R2 adapter or the S3 adapter, with the memory and failure behaviour stated plainly.
Migrate from libsignal-protocol-javascript or @privacyresearch/libsignal-protocol-typescript
A re-architecture guide with no wire compatibility, an honest comparison of the packages you are leaving, and a staged cutover that does not require a flag day.