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 hereTwo 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
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
inMemoryStore() contents | in memory only | no | n/a | lost on process exit |
inMemoryRelay() envelopes | n/a | in memory only | n/a | test-visible by design |
| Test fixture plaintext | yes | no | n/a | no |
message.content in onMessageDecrypted | yes | no | n/a | no |
| CI logs from a failing test | — | — | n/a | must not contain fixture keys |
Next
Error handling and retries
The full error taxonomy, which failures are retryable, and how to recover a session without making ratchet state worse.
Observability without plaintext
What you can safely measure in an encrypted system, what you must never emit, and why the obvious debugging fix is itself a vulnerability.