OpenE2EE

Testing encrypted flows

How to test a system whose contents you cannot read, and the failure paths teams routinely ship without covering.

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

Testing an encrypted application is unusual in one specific way: your test harness cannot inspect the thing under test from the outside. There is no assertion you can write against the relay's contents that proves correctness, because the relay never needs message plaintext or device private keys. Everything you verify, you verify by running both ends.

That constraint is what makes the in-memory adapters important rather than a shortcut.

The in-memory adapters are development only, and they are the right substrate

The documentation limits inMemoryStore() from /local/store/memory and inMemoryRelay() from /remote/relay/memory to development. They hold state in memory and lose it on restart. Do not include them in a release bundle.

Neither adapter is a test double. The protocol and the cryptography they run are the ones you ship; only the infrastructure under them is simulated. An assertion that passes on these adapters is an assertion about real handshakes, real ratchets, and real ciphertext.

Most protocol tests belong on these adapters. A test with real clients and a real relay also tests the network and platform storage. A failure does not identify which of the four parts failed. The in-memory pair removes transport and persistence so a failure identifies protocol or wiring behavior. Test persistence and transport separately with the adapters that you ship.

Use the in-memory adapters for protocol behavior. Use NodeSignalProtocolStore (/local/store/node) in CI to test state across a process boundary. This includes device records, sender keys, and message records, so you can also test multi-device and group persistence. Use your production adapter on a real device for platform storage, background execution, and key material at rest.

Two clients in one process

This is the canonical test shape. The SDK README states that CI extracts and runs this block against the packed package. CI runs it after each change.

import { createSignalProtocolClient } from "@open-e2ee/signal-protocol-sdk";
import { inMemoryStore } from "@open-e2ee/signal-protocol-sdk/local/store/memory";
import { inMemoryRelay } from "@open-e2ee/signal-protocol-sdk/remote/relay/memory";

const relay = inMemoryRelay();
await relay.registerDevice("alice", { encryptedDeviceName: new ArrayBuffer(0) });
await relay.registerDevice("bob", { encryptedDeviceName: new ArrayBuffer(0) });

const alice = await createSignalProtocolClient({
  identity: { userId: "alice" },
  adapters: { storage: inMemoryStore(), relay },
});
const bob = await createSignalProtocolClient({
  identity: { userId: "bob" },
  adapters: { storage: inMemoryStore(), relay },
});

await alice.syncToServer();
await bob.syncToServer();

// Decrypted content reaches your app here, and nowhere else.
bob.registerHook("onMessageDecrypted", async (message) => {
  console.log(`${message.senderId}: ${message.content}`); // alice: hello
});

await alice.send("bob", "hello");   // the relay now holds ciphertext and metadata
bob.startRelaySubscription();       // delivery and local decryption start here

Two properties of this shape matter. Each client gets its own inMemoryStore() instance: sharing one store between two identities produces a test that passes for the wrong reason. And the assertion lives inside onMessageDecrypted, because that hook is the only place decrypted content exists.

For advanced cases, adapters.protocolManager accepts an ISignalProtocolManager. The documentation defines it as an advanced and test override, not an app-level extension point. Use it when you need to drive protocol internals deterministically. Do not build product features on it.

The paths teams skip

The order reflects how often teams omit each test.

Out-of-order and skipped delivery. The Double Ratchet tolerates a maxSkip of 1000 and stores up to maxMessageKeysStored 1000 message keys. The keys expire after keyExpirationMs of 604800000 (7 days). Test delivery of messages 1, 3, 2. Then test 1, 400, 2. Push past the bound and verify that you get TOO_MANY_SKIPPED_MESSAGES. Your app must surface the error instead of looping.

Prekey exhaustion. Drive one identity through more session establishments than it has one-time prekeys. The one-time batch is 100. Assert you see PREKEY_NOT_FOUND and that checkPreKeyStatus() reported needsReplenishment before you got there. This is not a hypothetical: a 2025 measurement study of WhatsApp found 13% of companion devices lacked a one-time prekey at scan time.

Identity change mid-conversation. Establish a session, exchange messages, then replace one side's identity. Assert isIdentityKeyChangedError() fires with .changedAddress, .oldIdentityKey, and .newIdentityKey, that your UI blocks, and that nothing auto-accepts. Test the accept path separately through acceptIdentityRotation(...).

Session corruption and recovery. Damage a persisted session record and verify that you get SESSION_CORRUPTED. Then verify that deleteSession(remoteAddress) and re-establishment recover. Bound the number of recovery attempts. getSessionHealth(userId) should report the problem before the user does.

Offline queue and reconciliation. Send while the relay is unreachable. Restore it, then verify ordering and idempotency. MESSAGE_DUPLICATE on redelivery is correct behavior, and your handler must accept it. See offline and reconciliation.

Multi-device fanout. Two devices per user, minimum. The cost is multiplicative, (nA−1)·nB encryptions per message, so a fanout bug is a performance incident before it is a correctness one. Assert every device decrypts, and that a device added mid-conversation does not receive history: provisioning transfers identity, not sessions or history.

Group membership removal. This is the security-critical one. Remove a member with removeGroupMemberV2(...) and assert the sender key rotated and that the removed member's client can no longer decrypt subsequent messages. A test that only asserts the member disappeared from a list proves nothing. getGroupSenderKeyStats(groupId, senderId, senderDeviceId) returns generation, chainIndex, and skippedKeysCount: assert generation advanced.

Attachment round trip. encryptFile(...) / uploadAttachment(...) / downloadAttachment(...) / decryptFile(...) end to end, including an interrupted transfer. WebCrypto has no streaming API, an open bug since May 2016, so chunked framing is real code with real edge cases at chunk boundaries.

The SDK's own posture, stated honestly

The public repository does not contain the automated checks. A reviewer can reasonably treat a cryptography package without visible checks as unchecked. The public repository is a mechanized export of a private engineering repository. An allowlist controls each published file. The checks use internal module paths and cross-implementation material that the allowlist does not publish.

Public CI provides clickable logs for each push and pull request. It runs npm ci, npm run build, and npm run typecheck. It also runs npm audit --omit=dev at moderate severity against production dependencies.

The most recent full engineering run was 2026-08-10. It executed 384 modules and 6,893 assertions in 330 s. The run passed 6,891 assertions, skipped 2, and failed 0. An export comes only from a revision where the checks pass. Coverage includes conformance scenarios, published cryptographic vectors, protocol behavior, property checks, integrations, adapters, and the public API. Protocol checks include PQXDH establishment and skipped-key bounds. CI also runs the README quickstart as written after each change.

That is the honest shape: a genuine, independently reproducible public signal about compilation and dependency advisories, and a larger suite you cannot run yourself. Neither is an audit by a firm. The SDK is reviewed continuously by adversarial AI agents; it is not audited by any independent firm, and none is engaged.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
inMemoryStore() contentsin memory onlynon/alost on process exit
inMemoryRelay() envelopesn/ain memory onlyn/atest-visible by design
Test fixture plaintextyesnon/ano
message.content in onMessageDecryptedyesnon/ano
CI logs from a failing testn/amust not contain fixture keys

Next

On this page