Releases: open-e2ee/signal-protocol-js
Release list
v1.0.0
@open-e2ee/signal-protocol-sdk@1.0.0 changelog:notes
node ./scripts/check-changelog.mjs --section 1.0.0
-
Hosted Relay supports data-only push wake hints across Expo, APNs, FCM,
and Web Push. The SDK registers one destination for the authenticated
device and derives no account, device, scope, or generation identifiers from
application input. A wake starts an authenticated durable-mailbox pull.
The SDK acknowledges only envelopes that it processes successfully, and the
same pull remains available when push delivery is unavailable. -
Hosted Relay now supports provider migration, recovery progress, and
managed device linking. Migration assertions use the existing purpose-aware
identity callback with typed source and target roles. Recovery reports typed
phases while Relay remains the account authority. Managed linking requires a
provisioned account identity. It also requires distinct active and new
device-auth keys. It sends no ratchet, sender-key, skipped-key, or outbox
state. -
Breaking: sends use a durable exact-ciphertext outbox and always return a
logical-send identifier.SendResult.clientMessageIdis required. The SDK
generates it whenSendOptions.clientMessageIdis absent, exposes it on send
errors throughisOutgoingMessageError, and replays the persisted encrypted
transmission after an unknown Relay result. Receivers retain processed Relay
envelope identifiers for 30 days and acknowledge duplicates without running
protocol decryption again. Automatic sender-key rotation also persists its
pairwise distribution messages before group transport. Custom
ISignalProtocolLocalStoreimplementations must now implement
deleteMetadatafor bounded outbox and deduplication retention. -
Breaking: linked-device backup format 2 transfers account identity only.
A linked device generates fresh prekeys and establishes independent sessions.
The SDK rejects old or extended backup shapes that carry prekeys, ratchet
sessions, sender-key state, message state, or outbox state. -
Profile-key credential issuance is blinded. The authenticated issuer
receives an ACI-bound blinded request, not the raw profile key. The client
unblinds and verifies the response before it creates a group presentation. -
Breaking: sealed-sender delivery now uses one multi-recipient wire format
for direct and group sends. The direct path supports one recipient through
the same format, and the SDK rejects the retired V1 wire format. Hosted Relay
adapters no longer implement the V1 send method.preferreddelivery falls
back to identified delivery only after an anonymous authorization rejection;
other anonymous-delivery failures do not change the privacy route.
v0.4.0
-
The README now provides a runnable encrypted round trip and a current demo.
The quickstart exercises two SDK clients through the in-memory relay. The new
recording shows the same two-device and relay flow as the live desktop and
mobile demo. -
Error-surface checks reject unresolved codes and cover media attachment
codes. The analyser now reports forwarded code arguments in CI.
It also checks everyMediaAttachmentErrorCodevalue.
This release removesRemoteObjectStoreNotConfiguredbecause no media
operation could produce it. -
The retry-request wire surface no longer includes
RetryReason.INVALID_MESSAGE. No local failure produced it. The incoming
retry handler did not support it. Malformed ciphertext failures use the supported
DECRYPTION_FAILEDrecovery path. -
Generated API pages and shipped documentation examples now have source
gates. CI regeneratesdocs/apiwith pinned tool versions. It rejects a
diff. The snippet runner scans every packaged Markdown file and the internal
lifecycle docs. It executes every complete example and matches its output.
For an example that cannot run, it compiles every SDK name against the packed
package. A renamed or deleted export therefore fails the build in every
document that names it.An example that previews an unreleased API declares the import paths that do
not exist yet. The build fails if one of them resolves. Only pseudocode is
exempt, and the build names each exempt block on its own line. -
The error guide now documents all 33
EncryptionErrorCodevalues. Its
session examples also use the currentProtocolAddressmethod signatures.
v0.3.0
-
Breaking: seven error classes and sixteen
EncryptionErrorCodemembers are
removed, because nothing in the library ever threw them. Each class was
exported and paired with a type guard, so an application could branch on it —
but no code path constructed any of them, which made every one of those
branches dead code that read as handled. Removed, with theiris*guards:
IdentityKeyChangedError,SessionConflictError,
RegistrationIdChangedError, and the four intermediate classes
CryptoError,SessionError,ProtocolErrorandClientError. Removed
codes:IDENTITY_KEY_CHANGED,IDENTITY_KEY_ERROR,SESSION_CONFLICT,
REGISTRATION_ID_CHANGED,INVALID_REGISTRATION_ID,MESSAGE_TOO_OLD,
INVALID_MESSAGE_VERSION,RATCHET_ERROR,DATABASE_ERROR,
DATABASE_LOCKED,KYBER_ERROR,KDF_ERROR,HMAC_VERIFICATION_FAILED,
SPQR_KEY_ALREADY_USED,SPQR_EPOCH_REGRESSIONandUNKNOWN_ERROR. The
enum goes from 49 members to 33.What to use instead: an identity that changed is an identity that is not
trusted, and the library throwsUntrustedIdentityError/
UNTRUSTED_IDENTITYfor it from five sites on the session and sending
paths. Code branching onisIdentityKeyChangedErroror on
IDENTITY_KEY_CHANGEDwas never reached and should branch on
isUntrustedIdentityErrorinstead. The other removed names have no
replacement, because there was no event behind them. -
UntrustedIdentityErrorandisUntrustedIdentityErrorare exported from
the package root. They were reachable only from
@open-e2ee/signal-protocol-sdk/types/errors, while the error-handling guide
imported them from the root — so the first thing a reader copied did not
resolve.StorageQuotaExceededErroralso gains the
isStorageQuotaExceededErrorguard its five siblings already had. -
Every documented error code is now traceable to a construction site. A
check parses the library for the sites that decide whether a class can be
thrown, and fails the build when an exported error class or a declared code
survives with none — so the surface cannot drift back. Being named in a
switchcounted as use before, which is what hidIDENTITY_KEY_CHANGED
through all nineteen releases that declared it and never threw it.
v0.2.3
@noble/post-quantum0.6.1 → 0.7.0. Upstream hardening of the
primitives under the post-quantum ratchet: an intermediate-overflow fix
in ML-KEM's NTT multiplication, stricter input validation throughout,
and ML-KEM public-key cache detachment. The 0.7.0 breaking changes are
confined to hybrid-KEM aliases and a utils export, neither of which this
package imports. The bump also unifies the dependency tree on single
copies of@noble/hashesand@noble/curves2.3.0.
0.2.2
-
The unfixable
image-sizedevelopment dependency is replaced with an
inert local stub. Both open advisories againstimage-sizecover every
released version and have no fix. The package reaches the tree only
through Metro's image-asset pipeline, which nothing in this repository
exercises. An npm override now points it at a local package that throws
on use, so the development install carries no known-vulnerable code and
npm auditreports clean across the whole tree. -
Continuous fuzzing runs against the parser seams. ClusterFuzzLite
builds Jazzer.js fuzz targets over the wire-message, sender-key,
sealed-sender, and post-quantum ratchet decoders, the base64 conversions,
and the persistent session-record codec, on every push, pull request, and
manual dispatch. Writing the targets' rejection contract surfaced one
defect, fixed here: a session record with a malformed map encoding
escaped the codec as aTypeErrorinstead of its controlled error. -
The README carries the OpenSSF Best Practices badge. The project
earned the passing level at bestpractices.dev (project 14043). -
SECURITY.mdstates the supported release line correctly. The
supported-versions table had not moved past 0.1.x; it now names 0.2.x
and states the no-backports rule.
0.2.1
-
Two GitHub code-scanning findings in shipped code are fixed. The
URL-safe base64 conversions stripped trailing=padding with a/=+$/
replace, which backtracks quadratically on input that is mostly=
characters; padding is now stripped by index in linear time. The Node
store'sgetKeyprobed the key file withaccess()before reading it,
leaving a race window between the check and the read; it now reads
directly and treatsENOENTas an absent key. Neither fix changes
observable behavior on well-formed input. The scan's prototype-pollution
alerts against the Node sender-key trees were confirmed false positives:
every decoded dictionary level is rebuilt with a null prototype before it
is indexed, and the full-interface tests already prove__proto__keys
round-trip as plain data. -
Vulnerable development-dependency versions are updated.
js-yaml,
nanoid,postcss, andbrace-expansionmove past their published
denial-of-service advisories. The twoimage-sizeadvisories have no
fixed release yet; they sit behind Metro's dev-time asset parsing and do
not reach the published package, whose production dependencies audit
clean. -
@noble/ciphersmoves to 2.3.0. A routine minor update of the
symmetric-cipher dependency, applied internally so the public Dependabot
pull request can close against this release.
0.2.0
-
ML-KEM Braid key-agreement progress is now observable. A new
protocolStrategy.onBraidProgresscallback reports aBraidProgressEvent
after every braid-mode send and receive: the chunks this side has carried in
the current epoch, the chunks the open transfers account for, the epoch, and
whether the operation produced the epoch secret. Braid mode spreads one
ML-KEM key agreement across many messages, and until now the chunk counts
lived entirely inside the state machine, so a host had no way to show or log
how far a ratchet had travelled. A direct-mode session never raises the
callback. The hook is guarded exactly asonProtocolSelectedis: a consumer
that throws is logged and the protocol path continues. -
The web store's atomic commits leave no window for a partial commit
under tab death. A dying page closes its IndexedDB connection
gracefully, and a transaction that is idle at an await boundary then
commits the writes it already holds. The atomic session/trust commit
awaited each write in turn, so a tab death after the session write could
commit the session while the consumed one-time prekey survived — CI
observed exactly that once in Firefox. The identity-rotation commit
deleted sessions through an awaited cursor walk, with the same windows
between the new pin and each deletion. Both commits now enter every
mutation in one synchronous batch before any settles, and a structural
browser test fails on every run — no kill timing required — if a commit
regains an await boundary between writes. -
ML-KEM Braid key agreement completes. A braid session now reaches its
first epoch on both sides; before this it could not complete one at all,
failing at the 66th alternating message withFuture epoch requested.
Braid is the default SCKA mode, so this was the default path. The two sides
derive an epoch secret at very different times by design — the encapsulator
from a singleEncaps1over the header, the decapsulator only after
Decapsover the fullct1 ‖ ct2— and everything sent across that gap has
to go out under the epoch the peer can still follow.spqrSendtook that
epoch from the chunk it was emitting, so it lost it on exactly the messages
that carry no chunk: once its ciphertext is acknowledged, a sender has
nothing left to send until the encapsulation-key transfer finishes. It then
fell back to its own SPQR epoch, which had already advanced, and the peer
rejected the message. The braid state machine reports the epoch it is
sending under on every send, including the empty ones, andspqrSendnow
uses that for both the message-key derivation and the wire epoch. No wire
format, serialized state, or public API changes. -
Session state survives cloning under a cross-realm
structuredClone.
cloneProtocolStatenow checks once whether the ambientstructuredClone
returns objects of the calling realm, and falls back to its portable clone
when it does not. Reached across a realm boundary — the arrangement a Node
vmcontext produces, and one Jest builds for everything it runs —
structuredClonereturns a workingMapwhose prototype belongs to the
other realm, soinstanceof Mapreports false for it everywhere downstream.
Two silent losses followed: the session codec wrote such aMapas{},
and the portable clone rebuilt it as a prototype-only husk whosesize
getter throws. ML-KEM Braid felt this first, because its decoders are the
only protocol state held in aMapthat has to survive many clones — a
receiving decoder discarded every chunk it had accepted, held at one chunk
forever, and the key agreement could never complete. Single-realm hosts, the
browser and React Native included, always took and still take the native
path. -
Restoring a braid encoder state now rejects a malformed polynomial. Each
polyselement is a run of big-endian 16-bit coefficients. An odd-length
element read one byte past the end. The resulting
undefinedfolded into the low half of a coefficient. A truncated state
therefore decoded to a polynomial with a fabricated final term. The decoder
now refuses empty and odd-length elements, as the reference does. -
Encoding a braid message now rejects a chunk that is not 32 bytes. The
slot holds exactly 32 bytes. A shorter chunk left the rest of the slot zeroed and
encoded a chunk the sender never held. A longer chunk raised a bounds error
that named no field. Both now fail and report the length offered. The braid
state machine only ever produces 32-byte chunks. -
Deriving an SPQR send key now rejects a zeroed chain key. Two paths
install one. An epoch advance for receiving alone installs a zeroed send
chain key. Pruning writes one over each retired send chain. Deriving from
either would key a message from a known constant. No production path reached
that state, so the check is defence in depth. -
The unused SPQR binary header codec is gone.
serializeSPQRHeaderBinary
anddeserializeSPQRHeaderBinaryemitted an unversioned framing. No
production path used it. The documented wire format does not describe it. Use
the protobuf codec, which carries the version capability.
0.1.0
- First non-prerelease release. Identical in code to
0.1.0-alpha.14;
the version graduates the0.1.0-alphaseries. The alpha label is retired
because the condition it described no longer holds: every shipped store
adapter now carries the fullISignalProtocolLocalStorecontract with its
graduation checklist running as continuous CI gates — the storage contract
suites in real Chromium, Firefox, and WebKit, the backend-conformance kit
on Hermes, and the interruption, storage-pressure, multi-tab, and soak
suites. The0.xcaveat stands: public APIs and persisted formats may
change before1.0.
v0.1.0-alpha.14
-
ReactNativeSignalProtocolStoreis no longer experimental. Every gate
on its graduation checklist now runs continuously. A new
backend-conformance kit —runBackendConformanceand
assertBackendConformance, exported from the package — executes thirteen
cases against anyReactNativeKeyValueStorageimplementation: round-trips,
key listing, batch removal, in-order atomic application, checks evaluated
against pre-batch state, null-guarded creation, all-or-nothing failure,
exact-userId session removal with in-batch visibility, a single winner
between concurrent guarded batches, and durability across reopen. Negative
tests prove the kit catches a non-atomic backend, a prefix-matching
session removal, and an unserialized backend. A new
createReferenceReactNativeBackend— the executable specification of the
backend contract — passes the kit on the Hermes engine React Native ships
with, in a new CI gate that runs the bundled kit on the sha256-pinned
Hermes CLI and requires an explicit pass sentinel, because Hermes exits 0
on an unhandled async rejection. Interruption and storage-pressure jest
suites drive the adapter over the reference backend: a simulated process
kill before commit leaves every atomic security write all-or-nothing and
a retry after reopen lands it whole, and quota exhaustion is exercised
through every write path. -
Quota exhaustion in the React Native store now surfaces as a typed
error. The backend contract gains a documented quota signal: a backend
rejects the failing write with an error namedQuotaExceededErrorand
commits nothing. The adapter maps that signal to the public
StorageQuotaExceededError(codeSTORAGE_QUOTA_EXCEEDED) at the same
adapter-boundary seam the browser store uses, so both stores report
storage pressure identically.
0.1.0-alpha.13
-
IndexedDbSignalProtocolStoreis no longer experimental. Every gate
on its graduation checklist now runs continuously: the storage contract
suites in real Chromium, Firefox, and WebKit; the multi-tab
compare-and-set suite; the interruption suite; the storage-pressure
suite; and a new soak gate. The soak drives 2,000 full
construct/initialize/write/read/close cycles through the adapter in one
real Chromium page, samples renderer memory — including ArrayBuffer
backing stores, which plain JS-heap readings miss — after forced
garbage collection, and fails if the late-run median of memory or
per-cycle latency grows beyond a small tolerance over the early-run
median. Injecting a per-cycle leak into the adapter turns both
detectors red.npm run soak:web-storeruns longer sessions on demand.
The React Native adapter remains experimental; its checklist is
unchanged. -
Quota exhaustion in the browser store now surfaces as a typed error.
A write that exhausts the origin's storage quota rejects with the new
publicStorageQuotaExceededError(codeSTORAGE_QUOTA_EXCEEDED)
instead of the raw engineQuotaExceededError. The mapping lives at the
adapter boundary — one seam covering every store method, matched by
error name so it holds across engines — and because every write commits
in one atomic transaction, the typed error always means the write did
not persist, never a partial record. A jest suite drives quota-shaped
backend failures through every write path, and a new Chromium
storage-pressure spec in the real-browser gate clamps the origin's
quota, exhausts it against the real engine, and asserts the typed
rejection, intact prior state, and a clean retry once space frees. -
The real-browser gate now includes an interruption suite: a Playwright
spec destroys a real tab while a write is in flight — an atomic
session/trust commit, an identity rotation, a fresh-database bootstrap,
and a prekey batch — then reopens the store from a fresh tab and asserts
it is readable and each atomic security commit landed all-or-nothing,
never partially. The kills are sampled at several points inside each
write, in all three engines, and a deterministic structural test backs
them: each atomic security commit must open exactly one readwrite
transaction, which is what makes every kill land all-or-nothing. Tearing
either commit into two transactions turns the structural test red on
every run. -
Two tabs no longer race the IndexedDB store into silent data loss. Two
cross-connection races existed in the browser adapter. First, the database
encryption key bootstrap read then wrote in separate transactions, so two
tabs initializing a fresh database concurrently could each generate a
different key — records written by the losing tab then failed authenticated
decryption everywhere else. Second, first-contact trust pinning checked
then wrote in separate transactions, so two tabs pinning different
identities were both toldNEW_IDENTITYwhile one pin silently replaced
the other. Both paths now create through IndexedDB'sadd(), which refuses
to overwrite: the bootstrap loser adopts the winner's key, and a
first-contact loser re-evaluates its candidate against the pin that won.
Rotation, verification, and the atomic session/trust commit already
performed their compare-and-set inside a single transaction and are
unchanged. -
The real-browser gate now includes a multi-tab compare-and-set suite: a
Playwright spec drives two tabs — two live IndexedDB connections on one
origin — through concurrent key bootstrap, first-contact pinning,
rotations, verification-versus-rotation, and one-time-prekey consumption,
in all three engines. The two races above are what this suite caught, and
reverting either fix turns it red. -
Reading sessions or group sender keys no longer fails on Firefox and
WebKit.getSessionsForUserandgetAllSenderKeysForGroupin the
IndexedDB store decrypted each row inside the cursor loop. An IndexedDB
transaction deactivates the moment the event loop turns to anything that is
not one of its own requests, so awaiting Web Crypto mid-iteration let the
transaction commit and the next cursor advance threw
TransactionInactiveError. Chromium's laxer transaction lifetime and the
fake-indexeddb test double both tolerated the pattern, which is how it went
unseen. Both methods now collect the encrypted rows inside the transaction
and decrypt after it completes, the pattern the prekey reads already used.
No key agreement, payload, or wire byte changed. -
IndexedDbSignalProtocolStore.close(). The store held its database
connection open for the life of the page, and an open connection blocks
deletion and version upgrades of the database from anywhere else — so an
application signing out or clearing local state had no supported way to
release it.close()closes the connection and drops the in-memory
database key; callinitialize()again before any other operation. -
The storage contract suites now also run against
IndexedDbSignalProtocolStoreinside real Chromium, Firefox, and WebKit
pages on every change to the source repository. The suites are the same
modules the jest gate runs; the Firefox and WebKit failures above are what
the gate caught on its first run.