OpenE2EE

Errors

The complete error taxonomy — base classes, every EncryptionErrorCode, specialised classes with their type guards, and the retry rules.

Applies to @open-e2ee/signal-protocol-sdk 0.1.x. Public APIs and persisted formats may change before 1.0.

Every error the SDK throws extends EncryptionError. Branch on error.code, or use a type guard when you need the extra properties a specialised class carries. This page is the taxonomy. For how to structure handling around it, see error handling.

Where to import from

Most guards are on /types, not the package root

The package root exports EncryptionError, EncryptionErrorCode, and SealedSenderAuthError with isSealedSenderAuthError. Import every other narrowing guard through the /types subpath: isEncryptionError, isUntrustedIdentityError, isIdentityKeyChangedError, isSessionConflictError, isRegistrationIdChangedError, isDuplicatedMessageError, and isPQXDHRequiredError.

import { EncryptionError, EncryptionErrorCode } from '@open-e2ee/signal-protocol-sdk';
import { isIdentityKeyChangedError } from '@open-e2ee/signal-protocol-sdk/types';

Importing them from the root fails to resolve. See package subpaths.

There is no BadMacError

If you migrate from libsignal, you probably search for BadMacError. It does not exist in this package: the string "Bad MAC" appears nowhere in the source.

The failure it names surfaces here as EncryptionErrorCode.HMAC_VERIFICATION_FAILED. It often surfaces as DECRYPTION_FAILED when the boundary reports the failed decrypt to your code. Match on those codes, not on a class name or a message substring.

The same applies to error-message matching in general. Message text is not API. Codes are. See migrating from libsignal.

EncryptionError

The base class. Everything else extends it.

MemberTypeNotes
codeEncryptionErrorCodeAlways present; this is the branch point
contextEncryptionErrorContext | undefinedStructured detail
addressgetterConvenience read of context.address
operationgetterConvenience read of context.operation
originalErrorgetterThe wrapped cause, when the error wraps one

EncryptionErrorContext carries optional address, operation, identityKey, identity, originalError, and is open to additional keys.

import { EncryptionErrorCode } from '@open-e2ee/signal-protocol-sdk';
// Narrowing guards live on the /types subpath, not the package root.
import { isEncryptionError } from '@open-e2ee/signal-protocol-sdk/types';

try {
  await signal.send(recipientId, body);
} catch (error) {
  if (!isEncryptionError(error)) throw error;
  if (error.code === EncryptionErrorCode.IDENTITY_KEY_CHANGED) {
    // stop and surface to the user; do not retry
  }
}

isEncryptionError() is the outermost guard. Anything it rejects did not come from the SDK.

Tiered base classes

Four intermediate bases group errors by layer. Each carries a narrower code enum and the base EncryptionErrorCode. The tier-specific enum is more precise.

ClassGuardExtra membersIts code enum
CryptoErrorisCryptoError()cryptoCodeCryptoErrorCode
SessionErrorisSessionError()sessionCode, sessionId?SessionErrorCode
ProtocolErrorisProtocolError()protocolCode, protocolContext?ProtocolErrorCode
ClientErrorisClientError()clientCode, userFacingMessage?ClientErrorCode
EnumMembers
CryptoErrorCodeRNG_FAILURE, ECDH_FAILURE, KYBER_FAILURE, KDF_FAILURE, AES_FAILURE, HMAC_FAILURE, SIGNATURE_FAILURE, HASH_FAILURE, INVALID_KEY, INVALID_CIPHERTEXT
SessionErrorCodeNOT_FOUND, CORRUPTED, EXPIRED, CONFLICT, INVALID_STATE, RATCHET_FAILED, TOO_MANY_SKIPPED, MESSAGE_KEY_NOT_FOUND, ESTABLISHMENT_FAILED
ProtocolErrorCodeINVALID_MESSAGE, INVALID_BUNDLE, VERSION_MISMATCH, DEVICE_NOT_FOUND, STALE_DEVICE_LIST, RETRY_REQUIRED, SENDER_KEY_FAILED
ClientErrorCodeNETWORK_ERROR, VERIFICATION_REQUIRED, RECIPIENT_NOT_FOUND, DELIVERY_FAILED, ENCRYPTION_UNAVAILABLE, SESSION_RESET_NEEDED

ClientError.userFacingMessage is the only message in the taxonomy intended for display. Every other message is for logs.

EncryptionErrorCode

The full enum, grouped as the source groups it.

Session

CodeWhat it meansWhat to do
SESSION_NOT_FOUNDNo session with this addressEstablish one, or let send() do it
SESSION_CORRUPTEDStored session record is unusableDelete the session and re-establish
SESSION_CONFLICTTwo session establishments racedNot retryable; resolve, then re-establish
RECIPIENT_NOT_REGISTEREDRecipient has no registered deviceSurface to the user; there is nothing to retry against
PREKEY_FETCH_RATE_LIMITEDRelay throttled the bundle fetchBack off and retry later
SESSION_ESTABLISHMENT_FAILEDHandshake failedInspect originalError; often a bundle or relay problem

Message

CodeWhat it meansWhat to do
INVALID_CIPHERTEXTMalformed or truncated ciphertextDrop it; do not retry
DECRYPTION_FAILEDDecryption did not produce plaintextDrop the message; consider a retry request
ENCRYPTION_FAILEDEncryption failed locallyNot retryable; check session and key state
MESSAGE_DUPLICATEAlready-processed messageIgnore — this is a normal, safe outcome
MESSAGE_TOO_OLDOutside the accepted age windowDrop it
TOO_MANY_SKIPPED_MESSAGESSkip bound exceeded — maxSkip, default 1000Not retryable; the sender is too far ahead or something is wrong
REPLAY_DETECTEDA message key was presented twiceDrop it; this is a defence, not a glitch
INVALID_MESSAGE_VERSIONUnsupported message format versionPeer is on an incompatible build
SENDER_KEY_EXPIREDGroup sender key is past its lifetimeRotate and redistribute

Identity and trust

CodeWhat it meansWhat to do
UNTRUSTED_IDENTITYIdentity is not trusted for this operationStop; require an explicit user trust decision
IDENTITY_KEY_CHANGEDPeer's identity key changedNot retryable; show a safety-number warning and require acceptance
IDENTITY_MISMATCHPresented identity does not match the stored recordStop; treat as a trust event
SIGNATURE_VERIFICATION_FAILEDA signature did not verifyReject the material outright

Never auto-accept an identity change. See identity change and safety numbers.

Prekeys and session establishment

CodeWhat it meansWhat to do
INVALID_PREKEY_BUNDLEBundle is malformed or fails verificationNot retryable; a relay or peer bug
PREKEY_NOT_FOUNDRequested prekey is goneRefetch the bundle
PREKEY_ROTATION_REQUIREDLocal prekeys are too old to send withRotate; see maxPreKeyAgeMs
INVALID_REGISTRATION_IDRegistration id outside the valid rangeReject
REGISTRATION_ID_CHANGEDPeer reinstalled or resetTreat as a session reset event

Ratchet

CodeWhat it meansWhat to do
RATCHET_ERRORRatchet step failedInspect context; usually indicates state divergence
COUNTER_OVERFLOWMessage counter exceeded its boundRe-establish the session
INVALID_DH_KEYRatchet public key rejectedDrop the message

Storage

CodeWhat it meansWhat to do
KEY_STORAGE_ERRORStore failed reading or writing key materialRetryable in principle; check the adapter
DATABASE_ERRORUnderlying database errorCheck originalError
DATABASE_LOCKEDContention on the storeRetryable; withRetry() handles it

Initialization

CodeWhat it meansWhat to do
INITIALIZATION_FAILEDClient creation did not completeDo not use the instance; inspect originalError
IDENTITY_KEY_ERRORLocal identity key could not be loaded or generatedStorage or bootstrap-secret problem

Protocol strategy

CodeWhat it meansWhat to do
PQXDH_REQUIREDPost-quantum handshake required but unavailableSee PQXDHRequiredError below; possibly retry later
PQXDH_FAILEDPQXDH handshake failedInspect reason; fails closed rather than downgrading
TRIPLE_RATCHET_REQUIREDTriple Ratchet required but unavailablePeer cannot meet policy

SPQR

CodeWhat it means
SPQR_EPOCH_OUT_OF_RANGEEpoch outside the accepted window
SPQR_EPOCH_REGRESSIONEpoch moved backwards
SPQR_MESSAGE_JUMP_TOO_LARGEGap exceeds the bound
SPQR_KEY_ALREADY_USEDKey reuse detected
SPQR_COUNTER_OVERFLOWCounter bound exceeded
SPQR_INVALID_CIPHERTEXTMalformed SPQR ciphertext
SPQR_VERSION_MISMATCHUnsupported SPQR version

These are bounds checks in the post-quantum ratchet. They mean that the ratchet deliberately rejected a message. Drop it. If the error recurs, treat it as a signal that the peer states differ.

Cryptographic

CodeWhat it meansWhat to do
KYBER_ERRORML-KEM operation failedInspect originalError
KDF_ERRORKey derivation failedInspect originalError
HMAC_VERIFICATION_FAILEDAuthentication tag did not verifyThis is the "Bad MAC" case; drop the message

Sealed sender

CodeWhat it meansWhat to do
SEALED_SENDER_AUTH_FAILEDSender certificate or access key rejectedFall back to an authenticated send, or refresh the certificate

Generic

CodeWhat it means
INVALID_STATEThe operation is not valid in the current state
UNKNOWN_ERRORUnclassified failure

Specialised classes

Each carries a type guard and extra properties. Use the guard: instanceof across bundle boundaries is not reliable.

ClassGuardExtra properties
UntrustedIdentityErrorisUntrustedIdentityError()untrustedAddress, identity
IdentityKeyChangedErrorisIdentityKeyChangedError()changedAddress, oldIdentityKey, newIdentityKey
SessionConflictErrorisSessionConflictError()conflictAddress
RegistrationIdChangedErrorisRegistrationIdChangedError()resetAddress, oldRegistrationId, newRegistrationId
DuplicatedMessageErrorisDuplicatedMessageError()duplicatedAddress, counter?, epoch?, fingerprintPreview?
SealedSenderAuthErrorisSealedSenderAuthError()
PQXDHRequiredErrorisPQXDHRequiredError()remoteAddress, reason, retryable, suggestedRetryDelay?, fallbackType

The specialised classes prefix property names: untrustedAddress, changedAddress, conflictAddress, resetAddress, duplicatedAddress. They have no single error.address beyond the base-class getter that reads context.address.

PQXDHRequiredError detail:

PropertyValues
reason'no_kyber_prekey' | 'pqxdh_failed'
fallbackType'missing_keys' | 'crypto_failure' | 'protocol_mismatch'
retryableboolean
suggestedRetryDelay30000 by default when retryable is true

'no_kyber_prekey' with retryable: true is the common and recoverable case: the peer has not yet uploaded post-quantum key material. Wait suggestedRetryDelay and try again. The SDK fails closed here rather than falling back to a classical handshake: see security and protocol policy.

DuplicatedMessageError is not a fault. Delivery retries produce it. Swallow it.

Retrying

withRetry and isRetryableError come from @open-e2ee/signal-protocol-sdk/utils/retry.

import { withRetry } from '@open-e2ee/signal-protocol-sdk/utils/retry';

const result = await withRetry(() => signal.send(recipientId, body), {
  operationName: 'send message',
});

SignalProtocolRetryConfig

FieldDefaultNotes
maxRetries2Retries after the first attempt; total attempts is maxRetries + 1
baseDelay1000 msFirst backoff interval
maxDelay10000 msCeiling on any single delay
enableJittertrueSubtracts up to 25% from each computed delay
operationName'unknown operation'Appears in log lines only

Backoff multiplier is 2 and is not configurable.

Never retried

isRetryableError() returns false for exactly these codes:

CodeWhy
INVALID_PREKEY_BUNDLEThe bundle is wrong; time will not fix it
SESSION_CONFLICTRetrying compounds the conflict
ENCRYPTION_FAILEDLocal failure; state must change first
TOO_MANY_SKIPPED_MESSAGESA bound was hit deliberately
IDENTITY_KEY_CHANGEDRetrying past a trust event is the wrong behaviour, not a slow one

isRetryableError() returns true for anything it does not recognise, including non-SDK errors. Wrapping an operation in withRetry() therefore retries unknown failures by default. This default can help with transient network faults but can hide programming errors. Scope withRetry() to the calls you intend to retry, not to a whole handler.

Beyond the code list, isRetryableError() also inspects the message for transient patterns: network, fetch, connection, timeout, socket. Lock and busy. Race, concurrent, and conflict.

Next

On this page