OpenE2EE

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.

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

You cannot read the messages. The system requires this property, so you cannot log and inspect payloads. You can still use error codes, counters, session health, key ages, and envelope timing. This page defines useful diagnostics and the data that can break the system's security property.

The central warning

deviceredacterror codescounterssession health
Solid forms stay inside the device. Outlined codes, counters, and health values cross the redaction boundary into telemetry. These values carry metadata.

What the SDK gives you

client.logger is the injection point. The ILogger shape is debug, info, warn, error, and breadcrumb, all optional. Supply your own at composition time, or import the primitives from /logger. The default logger is environment-aware: in development it wires all five to the console. In production it emits only warn and error. Under NODE_ENV=test it emits nothing.

const client = await createSignalProtocolClient({
  identity: { userId },
  adapters: { storage, relay },
  logger: {
    warn: (message, data) => telemetry.warn(message, redact(data)),
    error: (message, error, data) => telemetry.error(message, toCode(error), redact(data)),
  },
});

redact is yours and it is the most security-relevant function in your telemetry layer. Write it as an allowlist of field names, never a denylist.

client.getStats() returns hasIdentityKey, oneTimePreKeysCount, and sessionCount. Three numbers, cheap, and a good heartbeat gauge.

getSessionHealth(userId) returns a SessionHealthResult: status, sessionExists, a message for UI display, issues[], checkedAt, a keyStatus block (hasIdentityKey, hasSignedPreKey, hasKyberPreKey, signedPreKeyAgeDays, kyberPreKeyAgeDays, needsRotation) and, when a session exists, sessionStatus with createdAt, lastUsedAt, ageDays, messagesSent, messagesReceived, isExpiredForSending, and isExpiredForReceiving. This is the richest per-peer diagnostic available without touching content.

checkPreKeyStatus() returns oneTimePreKeysRemaining and needsReplenishment. Graph the first, alert on the second persisting.

getSesameStats() returns totalUsers, totalDevices, totalActiveSessions, totalInactiveSessions, expiredSessions, and staleRecords. Rising staleRecords or expiredSessions means cleanupExpiredSesameSessions() is not running often enough.

getGroupSenderKeyStats(groupId, senderId, senderDeviceId) returns generation, chainIndex, and skippedKeysCount. generation is your evidence that a membership-change rotation actually happened.

client.syncStatus is 'synced' | 'failed' | 'none'. A population sitting on 'failed' is a prekey-publication outage in progress.

React hooks live at /hooks and are separate from registerHook(). useSessionHealth({ signal, userId }) returns { health, isLoading, error, refresh }. Use it for a per-conversation encryption-status indicator. useConnectionPresence({ relay, deviceId, enabled }) tracks device presence from connection and app-lifecycle state. It targets React Native and requires relay presence support. It is also available at /hooks/use-connection-presence.

Safe to emit, and never

Safe. Emit error codes from EncryptionErrorCode. Emit counters for decrypt results and session lifecycle events. Emit oneTimePreKeysRemaining and needsReplenishment.

Session health fields include status, ageDays, signed and Kyber prekey age, needsRotation, and isExpiredForSending. You can emit envelope counts, sizes, delivery latency, and subscription uptime. Adapter identity, SDK version, client.syncStatus, and sender-key generation are also safe.

Never. Do not emit message plaintext in any form. This includes the "first 20 characters for debugging" pattern, truncated text, and known-salt hashes. Do not emit private key material or derived data. This includes identity keys, prekeys, sessions, message keys, chain keys, root keys, and the storage-wrapping key.

Do not emit safety numbers or the numeric, emojis, and scannable outputs from generateCompositeSafetyNumber(...). A log with both peers' comparison data can impersonate the ceremony. Do not emit attachment plaintext, attachment keys, or presigned object-store URLs. These URLs are bearer credentials and "must not be logged or exposed beyond the authorized operation."

Handle with care. EncryptionError.context contents depend on the operation. Export allowlisted fields instead of the full object. .changedAddress on an IdentityKeyChangedError identifies a peer. Most privacy obligations treat it as personally identifiable information even though it is not key material. Raw ciphertext is unreadable, but long-term log retention still creates a deliberate data copy.

The two-sided problem

Matrix's undecryptable-message meta-issue catalogues over 70 distinct causes, and its operational conclusion is the one that should shape your support process: "we need logs from the receiver of the message and those from the sender. We generally can't debug issues without logs from both sides."

That has direct consequences.

Your correlation identifier must be content-independent and available on both sides. Derive it from the envelope. Store it in the sender's outbound record and receiver's inbound record. A support agent can then request the matching record.

Your support workflow needs a way to request diagnostics from a second user. Some reports from user A require data from user B. Without a consented collection mechanism for user B, these tickets remain unresolved. Design the consent flow before you need it.

Your diagnostic bundle needs a redaction pass that runs at collection time, on the device, before the bundle leaves. Redacting at the ingestion server means the unredacted copy already travelled: which is precisely how the Element leak reached the rageshake servers.

Accept a residue. Support cannot explain some undecryptable messages with evidence that you can legitimately collect. Give support an honest script for that case instead of an escalation path that ends in silence.

Opacity ledger

Telemetry fieldStays on deviceSent to relayIn object storeSafe in a log sink
EncryptionError.codeyesnonoyes
EncryptionError.contextyesnonoallowlisted fields only
getStats().sessionCountyesnonoyes
getStats().oneTimePreKeysCountyesnonoyes
checkPreKeyStatus().needsReplenishmentyesnonoyes
getSessionHealth().statusyesnonoyes
getSessionHealth().keyStatus.signedPreKeyAgeDaysyesnonoyes
getSesameStats().staleRecordsyesnonoyes
getGroupSenderKeyStats().generationyesnonoyes
client.syncStatusyesnonoyes
client.userId / client.deviceIdyesyesnoPII — treat as such
message.contentyesnononever
identityKeyPair private halfyesnononever
Session record (version: 4)yesnononever
generateCompositeSafetyNumber() outputyesnononever
Presigned object-store URLtransientnon/anever

Next

On this page