OpenE2EE

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:

  1. What happens to a message composed on a train?
  2. What happens when the phone holding the keys goes into a canal?
  3. 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 storage adapter 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.
A device with an attached local store holding its identity key, a seal gutter, a relay bracket holding three sheared envelopes as backlog, an open gutter, and an empty replacement device outline holding no keysdevice + local storeidentity keysealopenrelay: backlogreplacement: no keys
The original device holds the identity key and local store. The relay holds opaque backlog envelopes and exposes their metadata. The replacement device has no keys and cannot open those envelopes.

Install

npm install @open-e2ee/signal-protocol-sdk@0.1.0

On 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-sqlite

The 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:

BoundValueWhat it means when you hit it
maxSkip1000A message more than 1000 ahead of the last received counter is refused
maxMessageKeysStored1000Only 1000 skipped keys are retained at once
keyExpirationMs604800000 (7 days)A skipped key older than a week is dropped
MAX_UNACKNOWLEDGED_SESSION_AGE_MS30 daysA 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

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
Application outbox rowsyesnon/ano
Envelope ciphertextyesyesn/asize, routing, arrival time
Skipped message keys (maxMessageKeysStored)yesnon/ano
Session record (version: 4)yesnon/ano
client.syncStatusyesnon/ano
identityKeyPair private halfyesnon/ano
identityKeyPair public halfyesyesn/ayes
Prekeys (private halves)yesnon/ano
Prekeys (public halves)yesyesn/acount and consumption rate
Backup from sending.getBackup(sessionIds)yesencrypted, if routed that wayoptional, encryptedsize and write time
Backup encryption keyyes, or user-heldnon/ano
safetyNumber.numericyesnon/ano
safetyNumber.confirmationyesnon/ano
Trust state (UNVERIFIED_TOFU / VERIFIED)yesnon/ano
IdentityKeyChangedError.newIdentityKeyyesalready publicn/ayes
Your audit record of identity changesyesnon/ano
Encrypted device nameyesyes, encryptedn/athat 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

On this page