Repository navigation
v12.0.0
-
Breaking: every new session uses PQXDH, and the SDK has no classical key
agreement. A peer bundle without an ML-KEM prekey fails with
PQXDH_REQUIRED, and no setting allows a classical X3DH session.
'required'is the onlypostQuantumvalue.PostQuantumPolicy.Compatible
and the'compatible'value are removed, andpostQuantum: 'compatible'
throwsUnsupported postQuantum policyat client creation.
ProtocolStrategyConfig.allowClassicalFallbackis removed, and the SDK
ignores the key from an untyped caller.ClassicalFallbackReasonis removed
from./client/configand./types.ProtocolSelectionEventno longer
carriesusedClassicalFallbackorclassicalFallbackReason.
X3DH_INFO_DEFAULTis removed from./client/configand./types, and
ResolvedKeyExchangeInfoStringscarries onlypqxdh.PQXDHFallbackType,
PQXDHRequiredErrorOptions.fallbackType, andPQXDHRequiredError.fallbackType
are removed; readPQXDHRequiredError.reason(no_kyber_prekeyor
pqxdh_failed) instead. The X3DH module and itsperformX3DH,
performX3DHResponder,calculateX3DHSharedSecret,EphemeralKeyPair,
X3DHResult,X3DHResponderInput, andX3DHResponderResultexports are
removed.validateProtocolStrategyno
longer warns about a classical fallback. A stored 11.x classical session
continues as a Double Ratchet session. -
Breaking: the managed device link is the one link path. The SDK no
longer ships the device provisioning protocol.linkHostedRelayDevice()is
the one way to link a device. A linked device gets the account identity by
device transfer or by an application-owned channel. The managed device link
runs in one process that holds the active device's store and the new
device's store. It sends no private key to the Relay. These public names
are removed: the./device/provisioningsubpath and its exports
DEFAULT_PROVISIONING_LINK_PREFIX,LocalDeviceMetadata,
ProvisioningGroupStateStore,ProvisioningIdentityStore,
ProvisioningKeyPair,ProvisioningLocalStateStore,ProvisioningMessage,
ProvisioningQRData,ProvisioningReceiveOptions,ProvisioningSendOptions,
ProvisioningUsernameStateStore,UserProfile,cancelProvisioning,
connectToProvisioningSession,generateProvisioningQR,
parseProvisioningQR,provisionDeviceandreceiveProvisioningMessage,
with their./devicere-exports;PROVISIONING_SESSION_TTL_MSfrom
./device/constants;getDeviceMetadatafrom./device/expoand
./device/react-native;ProvisioningServicefrom./remote/relay/types;
theSignalProtocolRelayServermethodscreateProvisioningSession,
connectNewDevice,sendProvisioningMessage,getProvisioningMessage,
completeProvisioning,acknowledgeProvisioning,rollbackProvisioningand
deleteProvisioningSession, with theirHostedRelayHttpTransportmembers;
andDeviceChoice.requiresQR. A relay adapter drops these methods. -
Fixed: a blocked head of the mailbox no longer stalls the HTTP pull.
A mailbox pull, and sopullHostedRelayAfterWake, now sends the Relay
cursor of the page before it, so the pull moves past a full page of
unacknowledged envelopes and gives the messages and Relay receipts behind
it. Three kinds of envelope stay unacknowledged: an envelope that the
receive bounds refuse, a decrypt failure that no retry request owns, and
a retry request that waits for the next group fetch. The cursor lives in
memory only. An empty page, a page without a Relay cursor, a failed pull,
and a restart clear it, so the next pull starts at the head and offers
those envelopes again. The recovery pull after failed work also starts at
the head. Advancing
the cursor acknowledges nothing, so the Relay keeps each envelope. Before,
a full page of such envelopes came back on each pull, and the messages
behind it waited. -
Changed: hosted registration and recovery accept a native assertion with
nojti. The transport binds the challenge to the exact assertion,
purpose, and operation ID. The Relay verifies the configured provider and
rejects an invalid token. An assertion with a validjtikeeps that
identifier. -
Breaking: a send declares its device sets, and the Relay compares
them. Each post to an account declares the device IDs that the sender
holds records for.Envelope.declaredDeviceIdscarries the declaration of
a direct post, and the hosted transport sends it asdeviceSets.
SignalProtocolRelayServer.sendMultiRecipientUnidentifiedtakes a 9th
argument,deviceSets, for a sealed post. The relaysend()returns the
optionaldeviceSetCheckedandneedsSync.
sendMultiRecipientUnidentifiedreturns the optionaldeviceSetChecked
andrefusals. The Relay refuses a declaration that does not match with
409DEVICE_SET_MISMATCH, which names the missing and the extra devices,
or with 410STALE_DEVICE, which names the devices whose registration
changed. The SDK then removes each extra device record, starts a new
session with each missing or stale device, encrypts the message again only
for those devices, and posts again. The hosted transport finds each device
in the device directory before it posts, on the identified and on the
sealed route. A declared post to a device that the directory does not list
fails before the post with the 409DEVICE_SET_MISMATCHrefusal of the
Relay, which names the device as extra, so the SDK removes the device
record and posts again. A device that the Relay accepted keeps
its stored post. A send has a recipient phase and then a sync phase, and
each phase can restart 10 times. When the recipient phase does not match
after the 10th restart, or the Relay refuses a restart post with
OPERATION_EXPIRED, the send throws the new
DeviceSetReconciliationError. The outbox keeps the send as terminal, and
a call ofsend()with the sameclientMessageIdstarts a new run at 0
restarts. A group restart keeps the group ciphertext, seals the shared body
again, and replaces it in one write. A device-specific resend, a receipt,
and a retry request declare nothing. A custom relay and a wrapper of a
relay do not compare, get no declaration, and keep the device list reads.docs/DEVIATIONS.mdsections 5.5 and 5.6 compare this
loop with Signal's. -
Breaking: a send to a recipient with a session reads no device list on a
comparing relay. The Relay refuses a declared post that misses a device
or names a removed one, so the 60 s freshness window applies only to a
relay that does not compare. A recipient with no session gets one list
read to start the sessions. A group send declares the devices of its
records, and reads the list of a member only when it holds no record for
that member. A replay reads no device list. One exception holds in a live
send and in a replay. A repair that leaves an account with no device
record reads the list of that account once. -
Breaking: the own device list is read only on
needsSync. On a
comparing relay, the recipient phase posts first. Its receipt sets
needsSyncwhen the sender account has other devices. The sync phase then
declares the own devices that the sender holds records for. When the
sender holds no record of another own device, the send reads the own list
once. A failed read no longer fails the send. The recipient result is
stored first, and the result carriessyncPending: truewhen the sync
phase does not complete or the own list cannot be read. Callsend()
again with the sameclientMessageIdto post the pending copies. A replay
reads the own list again when the send could not read it. The SDK makes no
sync post without a destination. -
Breaking: a custom store keeps the device-set fields of an outgoing
intent. An outgoing intent addsdeviceSetRestarts,
deviceSetUnresolved,deviceSetBundleMissing, andsyncPending. A
custom metadata store must keep each field of the stored intent value,
also a field that it does not know.
The fields need no schema step, because they live in the existing JSON
value of the intent. -
Breaking: a refused group-token destination is no longer inferred as
stale. The group-token receipt names each refused destination with its
code inrefusals. ASTALE_DEVICEdestination leaves the shared group
message and gets the device repair once. ANOT_FOUNDdestination of a
declared post settles a removed account: the SDK adds nothing for it, and
uuids404names the account only when the receipt is not a duplicate.
SendResult.refusedDestinationCountstays a count, and the SDK infers no
code from it. -
Breaking: a repair fails the send on a session error other than a
missing bundle. When the start of a new session for a refused device
fails for another reason, such as an untrusted identity, the send fails
with that error. A listed device with no prekey bundle makes the send wait
and restart, and counts each restart. The repair write stores each such
device in the outbox. A replay after a process restart, a stop, or a
terminal end starts a session with each stored device before its first
post, so the device gets the message when it has a bundle again. When the
send holds no message for an account, a bundle fetch that the Relay
answers withNOT_FOUNDremoves the record of the device. The Relay gives
that answer for an unregistered device, and for a registered device with
no uploaded prekeys. Each other device that the account record holds gets
the message in the same repair when it has a session or a bundle. An
account left with no device record gets one list read, and a listed device
with no bundle waits again within the restart bound. Thus a replay reaches a device that the account linked
after an unlink. Before, a replay of a send whose recipient had no other
device failed withSESSION_NOT_FOUND, and a group replay completed
without the member. -
Added:
SendResult.syncPending. It is true when the Relay accepted
the send for every recipient device and the copies for the other devices
of the own account are not posted yet. -
Added: the
onRelayProtocolErrorhook andRelayProtocolError. The
Relay marks each accepted declared post withdeviceSetChecked. When an
accepted declared post has no mark, the send is delivered and its result
does not change. The client reports aRelayProtocolErrorwith the code
DEVICE_SET_UNCHECKEDand the route of the post once for the send through
onRelayProtocolError. A sync copy to another own device is a declared
post too, on theidentified-directroute. A sync payload of a method such
assyncReadToLinkedDevicesreports its error once with no
clientMessageId. The new exported types are
DeviceSetReconciliationError,RelayProtocolError,DeviceSetDevice,
DeviceSetLastRefusal,DeviceSetPhase,DeviceSetRefusalAccount,
DeviceSetUnresolved,DeviceSetUnresolvedReason,
RelayProtocolErrorCode, andRelayProtocolRoute. -
Added:
HostedAnonymousDeliveryError.data. The error keeps the
detailsobject of the Relay answer. For a refused declared post, it
names the missing, the extra, or the stale devices. -
Breaking: the skipped-key cap applies to each receiver chain. A
receiver chain keeps at mostmaxMessageKeysStoredskipped message keys,
2000 by default, as libsignal keepsMAX_MESSAGE_KEYSfor each chain. When
a chain is at the cap, its key with the lowest index leaves. A store to one
chain never evicts a key of another chain. Before this change, the cap
applied to all five chains together, and the oldest key of any chain left.
A session now keeps up to 2000 keys in each of its five chains. The 7-day
maxMessageKeyAgelimit does not change.countTotalMessageKeysand
evictOldestMessageKeyare removed from the double-ratchet exports. -
Breaking: a receive pass waits for each hook for at most 2 minutes.
The newprotocol.receiveQueue.receiveHookTimeoutMsoption sets the
bound. The default is 120000 ms. The value must be a positive integer of
at most 2147483647 ms, socreate()refuses zero, a negative value, a
fraction, andInfinity. The bound applies to each hook that a receive
pass awaits. At the bound, the SDK logs a warning with the
RECEIVE_HOOK_TIMEOUTbehavior and no longer waits for the hook. A
timeout ofonMessageDecryptedoronDeliveredcounts as one failed hook
attempt, with the normal backoff and attempt cap, and it logs no
RECEIVE_HOOK_FAILEDwarning. A timeout of another hook ends that call,
and the pass continues. A hook that settles after its bound writes
nothing. An application whose hook can run longer than 2 minutes sets a
larger bound. -
Breaking: a delivery receipt stops its retries 1 day after it becomes
due. One Signal client gives its delivery receipt job the same 1-day
lifespan.
When a receive record waits for its delivery receipt for 1 day,
the SDK completes the record. It sends no receipt, it does not call
onMessageDecryptedagain, and it gives noonReceiveExpiredreport,
because the application already has the content. A resend record still
reportsonDeliveryRecovered. The 30-day receive age still applies, so a
record whose age ends first completes at its age. Before this change, a
receipt retried until the receive age of 30 days. -
Breaking: SPQR skipped keys leave by index distance in each epoch, not
by age. The SPQR receive path now keeps held skipped keys as the
SparsePostQuantumRatchet chain keeps its key history. Each epoch keeps at
mostmaxOutOfOrderKeyskeys after a trim, 2000 by default. A key whose
index plusmaxOutOfOrderKeysis below the receive counter of its epoch
is refused withDECRYPTION_FAILEDand the reasonkey-trimmed. A
receive holds only the skipped keys at or above its own horizon. A jump
abovemaxOutOfOrderKeysclears the held keys of that epoch. When an epoch
holds 2201 keys, the keys behind the horizon leave. No eviction reads the
clock, and thetimestampfield stays in the persisted shape. When the
send epoch prunes an old epoch, the held keys of that epoch leave with its
chains, so a key of a pruned epoch no longer decrypts. Before this change,
the trim ran only when all epochs together held 2000 keys, and one receive
then added every key of its gap, up to 24999 keys. The internal
deserializeSPQRStateloader refused a state above 2000 keys. It now
keeps the highest 2200 indexes of each epoch, drops the keys of an epoch
with no chain, and logs one warning with the dropped count. The
cleanupSPQRStateexport andSPQR_CONFIG.MAX_SKIPPED_KEY_AGEare
removed from the SPQR module for the 12.0.0 cut, because no receive path
called them.trimSkippedKeysis replaced by
trimEpochSkippedKeys(state, epoch). -
Breaking: safety numbers order the two parties as libsignal does.
Each party gets its own 30-digit display string. The string that is lower
by byte comparison comes first, in the 60 digits, the emoji, and the hex.
The SDK no longer compares the two user identifiers, so no locale and no
identifier character can change the order. For some identifier pairs, the
displayed safety number of 12.0.0 differs from the one of 11.x. The 30
digits of each party do not change, and only their order changes. This
applies toclient.verify()andgenerateCompositeSafetyNumber, and also
togenerateSingleKeyReferenceSafetyNumber,Fingerprint, and
createFingerprintData. The composite QR profile version steps from 3 to
4 for the 12.0.0 cut. A QR code from an 11.x client then reports
version_mismatch, notno_match. The single-key QR profile stays at
version 2, because the new order makes its output equal to libsignal
version 2. -
Breaking: a safety-number confirmation from an earlier version is
refused.SafetyNumberConfirmation.versionsteps from 1 to 2 with the
composite QR profile step.confirmSafetyNumberrefuses a version 1
confirmation with the unsupported-version error, not with
IDENTITY_MISMATCH. The app callsverify()again and shows the new safety
number. -
Fixed: a cached safety number keeps the QR orientation of its caller.
Before, the cache gave the swapped call for the other party the same cached
result. Its QR code then carried the first caller's orientation, so a scan
against the other party's QR code reportedno_match. The cache now keeps
one entry for each orientation. -
Fixed: an archived session no longer sends a PreKeyMessage. When a
session moves to the archive, the SDK now clears its unacknowledged prekey
state: the pending flag, the prekey IDs, and the Kyber and KEM one-time
ciphertexts, as libsignal does. Before, a session that was archived before
the peer replied kept that state, and a later promotion made its next send
a PreKeyMessage for prekeys that the peer could already have deleted. The
in-memory store archive now follows the same record rule, so it also keeps
at most 40 archived sessions. -
Changed: archived sessions are evicted by insertion order, not by last
use. Over 40 archived sessions, the session that went into the archive
first leaves, as libsignal does. Before, the session with the oldest
lastUsedAtleft. A session archived again under the same base key moves
to the newest end. -
Breaking: a removed device keeps its record and its archived sessions.
SesameManager.removeDevicenow archives the current session of the
device, keeps the record, and marks it removed with the new
DeviceRecord.removedAtfield. Before, the removal deleted the record and
its sessions, so a message that the device sent before its removal did not
decrypt. Now that message decrypts on the archived session. A removed
device gets no fan-out: a send, a sync copy, a receipt, and a sender-key
distribution skip it. A send to a user whose devices are all removed fails
withNO_DEVICES. A second removal of the same device writes nothing and
emits no secondonDeviceRemovedevent. -
Changed: a device list that names a removed device again relists it.
The removed marker clears, and the device returns to fan-out with no
current session. The archived sessions stay. The relist emits
onDeviceAddedonce. A group send to a removed member device reads its
device list again before it gives the sender key. A device that is still
removed after that read gets no key. The new
SesameManager.relistDevicecall relists one device. -
Changed: a declared send counts a removed device record as absent.
On a comparing relay, a send declares only the devices whose records are
not marked removed. A repair that removes an extra device, or a device
whose bundle fetch the Relay answers withNOT_FOUND, marks its record.
A member or an own account whose every record is marked removed gets the
list read of an account with no record. A repair that starts a session
with a missing device relists its record, so the next post declares it. -
Changed: an identity change keeps the archived sessions. When a device
list gives a new identity key for a device, the SDK archives the current
session, takes the new key, and marks the device pending verification.
Before, it deleted the record and its sessions and added the device again,
with anonDeviceRemovedand anonDeviceAddedevent. Now a message under
the old session still decrypts, and the change emits no device event. A
removed device whose identity changes is relisted, with one
onDeviceAddedevent. -
Changed: the client deletes stale device records at each start. The
receive queue runscleanupStaleRecordsonce in each run, after its
resume. A removed record goes when its removal is older thanmaxLatency,
30 days by default. The cleanup also deletes a record that has no session
and is older thanmaxLatency. Before, no SDK path ran the cleanup. -
Breaking: a custom store keeps and deletes removed device records. A
SesameStoremust keep theremovedAtfield of a device record.
deleteStaleRecordsmust also delete a record for which
isExpiredRemovedDeviceis true.getSesameDeviceIdsstill returns the
removed devices, and a caller that sends to the devices skips them with
isRemovedDevice. -
Breaking: the SQLite schema moves to version 6 and the IndexedDB database
to version 11. Neither step changes a table or an object store. The steps
keep an earlier SDK, which sends to a removed device, from opening the
store. An earlier SDK refuses the SQLite file with
SqliteSchemaTooNewError, and the browser refuses the IndexedDB open with
aVersionError. A downgrade is not supported. -
Added:
isRemovedDeviceandisExpiredRemovedDevice. The two
predicates are exported from thetypesentry point.isRemovedDevice
tells whether a device record is marked removed.isExpiredRemovedDevice
tells whether the stale-record cleanup deletes a removed record. -
Changed: a waiting receive record holds back only its conversation. A
record that waits for its group state, or for a hook retry below the
attempt cap, holds back the later records of its conversation, as one
Signal client queues its message jobs. Group content keys by its group,
a sent transcript of this account keys by its destination, and other
content keys by its sender account. Before this change, a waiting record
held back every later record of its sender device, in every group and in
direct chats. Now direct chats and other groups make progress, and a
blocked group holds back its later content from every sender. A record
that waits behind an earlier record of its group counts toward the share
of that group in the receive queue, so a stalled group reaches its share
sooner, and its new sender-key messages wait at the Relay. The order in
which the device commits its records stays per sender device. -
Added:
discardPendingReceiveremoves a receive that the application
cannot process. The call takes themessageIdthat
listPendingReceivesgives. It removes a receive that has not reached its
delivery receipt, and returns the removed receive, ornullwhen no
listed receive matches. It sends no receipt, it does not call
onMessageDecrypted, and it gives noonReceiveExpiredreport. A
redelivery of the envelope then givesduplicateand decrypts nothing.
A hook call that already started still runs. Each
PendingReceivenow giveshookAttempts, the count of failed
onMessageDecryptedattempts. ItsmessageIdis now always set: a
receive stored before the client kept message IDs gives its record ID, as
theprocessedoutcome does. -
Changed: group sends and session locks order the tabs of one origin.
Whennavigator.locks.requestexists and the store has a
storeIdentity, a group Sender Key operation runs under the Web Lock
open-e2ee:group-send:<store identity>:<lock key>. The session lock, the
user lock, and the device-list sync lock run under
open-e2ee:session:<store identity>:<lock key>. Two tabs or workers of
one origin on one store then encrypt in turn, so two group messages never
carry one iteration and two direct messages never carry one counter.
Before this change, each tab had only its own in-process lock. A page or
engine with no Web Locks, and a store with no identity, keeps the
in-process locks. Two tabs on one store are then not ordered, and the
application must give one tab exclusive access to the store. The session
lock and the device-list sync lock still end a wait after 5 seconds. On
the Web Lock path the wait rejects withORIGIN_LOCK_TIMEOUT_MESSAGE, and
on the in-process path with the message of the in-process lock. A stop of
the relay work still ends the wait of a group operation. A Web Lock has no limit on its waiting requests. The
in-process session lock still refuses a request when 1,000 requests wait. -
Added:
storeIdentityon the local store contract. The optional
member names the data of a store, so that each tab and worker of an
origin gives the same name for the same data. The IndexedDB store and the
web SQLite store give their database names. A
custom web store that shares its data across tabs sets it, so that its
sends take the Web Locks. -
Fixed: a sender-key rotation keeps the states that another client
wrote. EachSenderKeyManagerkept its own copy of the previous
states, and a rotation or a new distribution built the archive from that
copy. When two clients on one store rotated in turn, for example in two
tabs, the archive lost the states that the other client wrote, so the
earlier messages of those states no longer decrypted. The manager now
reads the archive from the stored record each time, and the record keeps
at most 5 states. -
Breaking: a group send gives its sender key to each device that has no
handoff row. The SDK keeps a sender-key handoff row for each device of
another member when the Relay accepts a distribution of the own sender key
for that device. The row names the group, the sender key ID, the
generation, the recipient device, and the registration ID of the session.
A group send reads the device list of each member. It gives the current key
to each listed device that has no row for the current sender key ID,
generation, and registration. A row records a handoff. It is not proof
that the device installed the key. The SDK writes the row after the Relay
accepts the distribution and before the send marks the distribution sent.
A dropped distribution writes no row. A new own key, a rotation, and a
delete of the own key delete the rows of the group. A PreKey session from
the device, a bundle session that replaces a current session, a session
archive, a session delete, and a device-record delete delete the rows of
the device in every group. An identity rotation of a contact deletes the
rows of each device of that account. A retry request deletes the rows of
the requester in the group of the failed message only. A session reset
after an expired retry request deletes the rows of each device of the
sender account in every group. Client start
deletes each row that no current own key covers.docs/DEVIATIONS.md
section 7.5 compares this record with the record of the Signal apps and
gives the reason for each difference.
SenderKeyState.distributionPending,
SenderKeyManager.getPendingSenderKeyDistribution, and
SenderKeyManager.confirmSenderKeyDistributionSentare removed.
SenderKeyManager.rotateSenderKeyandrotateGroupSenderKeydo not take
options.distributionPending. The manager ignores adistributionPending
field in a stored state, and the next write of the state omits it. -
Breaking: a custom store keeps the sender-key handoff rows.
SignalProtocolLocalStoreextends the newSenderKeyHandoffStore. A
custom store must implement its six methods:recordSenderKeyHandoff,
listSenderKeyHandoffs,invalidateSenderKeyHandoff,
invalidateAccountSenderKeyHandoffs,listSenderKeyHandoffGroupIds, and
retainSenderKeyHandoffs. Each row delete must be in the same write as
the change that causes it.
storeSenderKeyRecordanddeleteSenderKeytake the new
SenderKeyRecordWriteOptions. WithdeleteSenderKeyHandoffs, the write
deletes the rows of the group.setDeviceRecordtakes the new
SetDeviceRecordOptions, andSessionTrustCommithas the field
senderKeyHandoffInvalidation. With the device, the write deletes the
rows of that device in every group.archiveCurrentSession,
deleteSessionRecord,deleteDeviceRecord,
deleteAllSenderKeysForGroup, and
acceptContactIdentityRotationAndDeleteSessionsalways delete the rows
of their device, group, or account. The memory store keeps the rows in its
state. The IndexedDB, key-value, and SQLite stores keep the rows durably,
so a restart does not give the key to a listed device again. The SQLite
stores add thesender_key_handofftable in schema version 5, and the
IndexedDB store adds thesenderKeyHandoffobject store in database
version 10. The IndexedDB and key-value stores encrypt each row and find it
by keyed hashes, so they hold no group ID, account ID, or device in the
clear. -
Breaking: a custom key-value backend must apply
removeSenderKeyHandoffsinsideatomicWrite.KeyValueOperationhas
the new variant. The part of a key afterkeyPrefixhas five
colon-separated segments: the group hash, the generation, the account
hash, the recipient hash, and the entry hash. The backend removes each key
whose group, account, and recipient segments equal every given hash, and
keeps a key of another shape. It applies the operation in the same
transaction as the other operations of the batch, and the operation
observes the earlier writes of the batch. A backend built with
createTransactionalKeyValueBackendapplies it already.
assertBackendConformancechecks it. -
Breaking: the group outbox context and the sync send change.
GroupOutboxContextdoes not haveconfirmSenderKeyDistributionSent.
It has the required membersrecordSenderKeyHandoff,
prepareSenderKeyHandoffs,resolveOwnDevices,
isConclusiveDeviceRejection,repairStaleDevice, and
postSyncMessage.postSyncMessagetakes, as its third argument, a
function that encrypts the transcript again for one own device.
prepareSyncMessagestakes the own devices of the send as its fifth
argument.postPreMessageresolves to the new
PreMessagePostResult.appendOutgoingGroupDeviceMessagesis removed.
reconcileOutgoingGroupDeviceSetremoves the messages of devices from an
intent and adds new messages in one write.
DefaultSesameManager.sendToLocalOtherDevicesrequires
listedDeviceIds, and it sends only to those own devices. -
Changed: each send gives a sync copy to exactly the listed own
devices. On a relay that does not compare device sets, each direct or
group send, and each replay, reads the device list of the own account,
with no freshness window. A failed
read stops the recipient post and fails the send, also for an account
with no linked device, and the outbox retries a stored send. The first
attempt of a send that fails on this read throws before the SDK stores
it. The error carries theclientMessageId, but the outbox holds no
transmission for it. The application callssend()again, and the SDK
encrypts the content again. The send gives
a sync copy to each listed own device other than the sending device. Each
sync copy is keyed by its device. An own device gets the transcript and
never a sender-key handoff row. When the Relay refuses an own device as
stale, the SDK refreshes the session of that device once and encrypts the
copy again for it. For the transcript of a stored send, the SDK replaces
the copy in the intent and posts the new copy under a repair ID. The
device then gets the transcript of that send. When the Relay refuses the
new copy too, the SDK archives the session, removes the copy from the
intent, and completes the send. When the refresh fails, or the encryption
for the new session gives no copy, the send fails. The copy and the new
session stay, and the outbox retries the stored send. A sync copy of
account state, such as a read sync, gets the same refresh. The SDK posts
the new copy again with no operation ID and changes no intent. -
Changed: a group replay reads the device lists again. On a relay that
does not compare device sets, before the replay of a stored group send, the
SDK reads the device lists of the members and of the own account. One intent write removes each message of
a device that the lists do not show. A listed device that the stored send
does not reach gets the current key of the send and the stored group
ciphertext. When the sender rotated the key after the send, the added
device gets only the stored group ciphertext, and the replay completes.
The device recovers the content through its retry request, and the next
send gives it the current key. The stored ciphertext does not change, because the Relay
refuses another operation under the same message ID. Thus a device that
the replay adds gets the content only through its retry request and the
resend of aResendablesend. -
Breaking: a group state store keeps a receive record for each group.
GroupStateStoreaddsgetGroupReceiveRecord,
updateGroupReceiveRecord, andlistGroupReceiveRecords. A receive
record holds the group's required revision, which is the highest revision
that an authenticated group context has claimed, and the fetch backoff and
the last verified denial. A custom store must implement the three methods.
An update that returns null deletes the group's whole record. The SDK
store writes version 2 group records with an index. It migrates a version
1 record forward when it first reads or writes that group, and a crash at
any migration step reruns safely. A migrated group starts at required
revision 0 as a locally known group. A record that a context created
before the group's state was verified also names the sender device that
claimed it.storeGroupStateand
storeGroupStateWithSenderKeyRotationBarriermust remove that origin in
the same write that stores the state. A custom store must do the same. -
Breaking: managed group content carries its group context. Each
send to a managed group, its sync transcript, and its send log entry now
carry the group's master key and the revision of the sender's verified
state inside the encrypted content, as Signal'sGroupContextV2does. A
resend therefore carries the context of the original send. The receiving
client removes the context before it gives the content to the
application, so the application gets the bytes that the sender sent. An
ad hoc group send carries no context.SignalProtocolContentAdapteradds
two required methods,serializeContentWithGroupContextand
withoutGroupContext, andinspectContentreportsgroupContext, or
'malformed'for a context that does not decode. A custom adapter must
implement both methods and report the context. The default adapter puts
the context in agroupContextmember of a JSON object and wraps any
other content in agroupContentmember. -
Breaking: a managed group receive follows Signal's order. The
client decrypts, runs the replay check, and then installs a sender key
distribution with no group decision, as Signal does. Membership,
permission, and blocking no longer stop the install. After the install,
the client derives the group ID from the context's master key and
compares it with the group of the envelope and of the distribution. A
context that does not decode is refused asmalformed-context, and a
context of another group ascontext-mismatch. Content that a sender
key decrypts, or that comes beside a distribution, is content of that
group in every shape. Sync content is content of this account. On the
Relay subscription, inreceiveIncomingEnvelopes, and in
processIncomingEnvelopeanddecryptGroupMessage, content from
another account that carries a sync message is refused as
sync-from-other-accountbefore the distribution install and before
any group decision, and the application gets no content. The raw
decrypt methodsreceive,decryptMessage, anddecryptMessages
return the plaintext with no group classification and do not refuse
it. Sender-key content from another account is delivered under its
group, never under a conversation that its content names. When the
local state is
below the revision that the context claims, one fetcher brings the group
up to date before the client decides. The client decides each message
at the revision that its own context claims, so a false claim uses up
only its own fetch. The fetcher runs at most one fetch for each claimed
revision and at most 16 claims in a backoff window. A fetch that ends
below the claim is complete: the client decides that message at the
fetched state, the group's required revision becomes the server head,
or a higher claim that came while the fetch ran, and the backoff does
not grow. Only a fetch that fails or that the
server refuses backs off. A claim above the fetched state that the
window cannot fetch waits for the next window, so false claims hold an
honest claim for 30 s at most. When the trust
root has a server signing key, a context that carries the signed change
to the claimed revision applies that change with no fetch, also when
the window has fetched its 16 claims. Under
allowUnauthenticatedGroupHistory, the client never applies a carried
change. Content with no context is decided at the local state, and is
refused aslegacy-no-contextwhen the client has no state. While the
group cannot update, the content waits on the device in the receive
record that the decrypt committed with the ratchet state, and the receive
queue decides it when a fetch of its group completes. The client sends
no retry request and does not callonDecryptionError. The fetcher
tries again in the background and at start.decryptGroupMessagethrows
GroupReceiveUnavailableErrorfor such content, and the error carries
themessageIdof the record. A second call with the same message
decides the stored content with no second decrypt, also after a restart.
Two concurrent calls with the same message give the content once. A sender
device can make at most 100 groups wait for verification. Past the cap,
a context for a new group is refused asunverified-cap. The new
groups.isGroupBlockedconfig drops the content of a blocked group with
no report and no fetch. The background fetcher also skips a blocked
group, at start and on its timer. A block hook that throws any value
skips only its own group.decryptGroupMessagenow decrypts before it
decides, so a refused message uses its message key.syncGroupnow runs
through the fetcher, so it joins a fetch in flight and records its
result. It returns that result only when the fetch called the group
server. After a fetch that only tried a carried change, it waits for the
next fetch, its own when no other caller starts one first. An ad hoc
distribution installs with nogroupsconfig. The
package root and thecliententry exportGroupReceiveUnavailableError,
GroupReceiveRefusedError, andGroupResendDeferredError.
processIncomingEnvelopereturnspendingfor content that waits for
its group anddeniedfor refused content, and the caller acknowledges
the envelope in both cases. A deferred resend leaves the retry request
with the Relay. Seedocs/ERROR_HANDLING.md. -
Breaking:
processIncomingEnvelopereturns a receive outcome. The
method now returns aReceiveOutcomein place of the plaintext. Its
kindisprocessed,pending,duplicate,null-message,denied,
orrefused. Acknowledge the envelope for each kind exceptrefused,
which means that the receive queue has no room: nothing is decrypted or
stored, so give the envelope again later. Aprocessedreceive keeps
its content on the device until the application calls the new
completeReceivewith itsmessageId, or until the newonPersist
option resolves. A redelivery before then returns the same outcome with
no second decrypt. A Relay delivery of an envelope that
processIncomingEnvelopecommitted reuses that record and installs no
sender key distribution from it. The newlistPendingReceiveslists the stored
receives one page at a time, and{ ready: true }lists only the
receives that wait forcompleteReceive. The new
processPendingReceivesruns one pass of the receive queue now, and its
deadlineMsoption bounds the pass.processIncomingEnvelopesreturns
{ envelope, outcome }or{ envelope, error }for each envelope.
Before, the method returned the plaintext and deleted the stored content
before the application saved it, so a crash between the two lost the
message. -
Breaking: a custom store commits a receive record with the ratchet
state.SessionTrustCommit.receiveWorkand
SenderKeyReceiveCommit.workreplacereceivedContentandcontent.
The store must write theReceiveWorkRecordin the same transaction as
the session or sender key state, so the ratchet step and the record roll
back together. The store addsgetReceiveWork,
putReceiveWork,listReceiveWork,receiveWorkUsage, and
completeReceiveWork, and removesgetReceivedContent,
deleteReceivedContent, anddeleteExpiredReceivedContent.
listReceiveWorkreturns records in arrival order, byarrivalSeqand
thenworkId, and itsafterquery takes the last record of the page
before, so records with an equalarrivalSeqare never skipped.
putReceiveWorkandcompleteReceiveWorkcompare the record revision
and write nothing on a mismatch.completeReceiveWorkdeletes the record
and writes its metadata in one transaction.
SignalProtocolManager.decrypttakes areceiveWorkseed in place of
receiveId. The SQLite schema version 4 and the IndexedDB schema version
9 add only thereceive_worktable and object store. After the first
open, an SDK earlier than 12 cannot open the store. There is no
downgrade path. At open, each SDK
store converts the received content rows of SDK 11 to receive records
that wait for the redelivery of their envelope, and writes its completion
marker last, so a stop during the conversion is safe. A row that does not
parse stays in place, and the store reports it with no content: the
SQLite store through its logger, the other stores through
console.error. When the Relay subscription,receiveIncomingEnvelopes,
orpullHostedRelayAfterWakegets the redelivered envelope of a converted
record, and the client already processed that envelope, the client
completes the record with no hook call. -
Fixed: a receiver installs a sender key that the sender creates again.
processSenderKeyDistributionmatches a key by its sender key ID, as
Signal's published implementation matches a state by its chain ID and
signing key. The generation rules apply only to a distribution of the
current key, and a later generation of that key replaces it in place. A
distribution of a kept previous key changes nothing. A distribution of
another key becomes the current state, and the earlier current state
becomes a previous state, so a message in flight under it still decrypts.
Before, a receiver dropped each distribution whose generation was not
above the generation of its current state.createGroupSenderKeyover an
existing key, and a delete followed by a create, start again at
generation 1, so each receiver that held the earlier key failed every
later group message of that sender device withSENDER_KEY_MISMATCH,
until a rotation took the sender's generation above the one the receiver
held. -
Fixed: every send error carries the logical-send identifier.
send()chooses theclientMessageIdbefore any other step, so
isOutgoingMessageErroris true also for an error before the encryption,
such as a failed content serialization or a failed read of the own device
list. The application callssend()again with the same
clientMessageId. Before, only an error of the encryption carried the
identifier. -
Fixed: a group resend checks membership at the required revision.
Before a sender gives group content again, it brings a managed group
up to the highest revision that an authenticated context claimed. The
check uses the same receive record, fetch window, and backoff as a
receive. A requester who is not a member at that revision gets no
content, and the answer ends asmembership-refused. When the update
is fresh, the sender answers from the local state, also when a false
claim keeps the required revision above the server head, so a removed
member who claims a false revision is refused and not deferred. When
the update fails or waits for its backoff, the sender defers the resend
and does not refuse it. The resend job stays pending for the resend job
driver, and the retry request envelope stays unacknowledged for the next
Relay delivery. Before, the sender checked a stale local state, so it could
resend to a removed member or refuse an added member. -
Fixed: unacknowledged messages no longer stall the socket backlog.
The mailbox socket now ends each page of durable frames with one
acknowledgeframe, with no ID when the page acknowledged nothing. The
Relay sends the next page of a backlog only on that frame. Before, a page
of messages that all stayed unacknowledged stalled every page behind it
until the socket reconnected. -
Fixed: received content keeps the time of its first admission.
DecryptedEnvelope.receivedAtis the time the device first committed
the content. A first pass, a retry, and a delivery after a restart give
the same value. Content with no timestamp of its own gives this time as
itstimestampfor receipt matching, so each delivery gives the same
timestamp. The 30-day receive age of stored content
also counts from this time, through a retry, a redelivery, and a
restart. Before, the first pass read the clock again after it stored
the content. -
Changed: the client acknowledges a Relay message after its commit.
On the Relay subscription and inpullHostedRelayAfterWake, the client
decrypts each envelope and commits its content as a receive record in
the same transaction as the ratchet step. It acknowledges the envelope
only after that commit, and sends no acknowledgment when the commit
fails, as Signal does. The receive queue then decides the group, gives
the content toonMessageDecrypted, and sends the E2EE delivery
receipt. That receipt is the only proof that the application processed
the content. A Relay delivery receipt marks only the handoff of the
content to the device. Content that waits for its group state stays on
the device, not at the Relay. A hook that throws is tried again after a
backoff. The device stores the count of each attempt before it calls the
hook, so a hook call that ends the process also counts as an attempt.
After 10 attempts, the content no longer holds back the
later content of its conversation. Each start then offers it to the hook
once: in the start pass, or at the registration ofonMessageDecrypted
when the start pass had no hook. After that offer, only
processPendingReceivestries it again before the next start, until it
expires or the application discards it. A pass of those that ends before
the retry, at a deadline or at a failed page read, leaves the retry to
the next pass. That retry runs after the other content
of the device, so a hook call that ends the process on it does not stop
the later content of its conversation.pullHostedRelayAfterWakecommits, acknowledges, then
runsprocessPendingReceives, and its newdeadlineMsoption bounds
that pass. It gives no content to the application before the
acknowledgment settles.HostedRelayWakeClientadds
processPendingReceives.receiveIncomingEnvelopescommits each
envelope and sends no acknowledgment. The content reaches
onMessageDecryptedin a later pass of the receive queue, after the
call returns. Afterstop(), the content waits for the next start of the
queue fromstartRelaySubscription, or forprocessPendingReceives. At
start, the client admits a new envelope only after its first read of the
stored content ends, so new content of a sender device comes after its stored
content, also when the clock stepped back across the restart. This order
holds only when that first read completes. It does not hold after a first
read that failed, after astop()during the first read, or for an
envelope received before the first start. Two processes that share one
store, such as two browser tabs, each keep their own arrival order, so
content of one process can come before content that the other process
received earlier. Before, the client acknowledged only after the
application had the content, so group content that waited for its group
stayed at the Relay and came back on each delivery. -
Added: a bounded receive queue with an expiry hook. The new
protocol.receiveQueueconfig sets how much the device holds. Stored
content may wait 30 days, counted from its first commit on this
device. The age is fixed.protocol.receiveQueuehas no age field, and
a client created withprotocol.receiveQueue.maxAgeMsis refused with
an error that names 12.0.0 and the fixed 30 days. Content expires when
its age is equal to or more than
30 days. The earlier prune deleted content only at an age of more
than 30 days, so the expiry now comes 1 ms sooner.maxItemsis 3000 andmaxBytesis 750 MiB by default. They are
provisional until a measurement sets them.blockedGroupShare, 0.25 by
default, is the part of each bound that the waiting records of one group
can fill. At a bound the client decrypts nothing more and does not
acknowledge the envelope, so the Relay keeps it. A refused envelope
expires at the Relay at the TTL of the Relay project, and no local hook
reports that expiry. While the mailbox of the device is full, the Relay
refuses a new message to it withMAILBOX_FULLto its sender. The new
onReceiveExpiredhook gets aReceiveExpiredEventfor content that
waited 30 days, before the client drops it. Before the hook, the
client reads the record again, so content that the client completed
before that read, such as at the redelivery of its envelope, gets no
report. Before, the client deleted stored content after 30 days with no
report. -
Added:
decryptEnvelopedecrypts one envelope with no receive
record. It installs a sender key distribution that the content
carries and checks the group context against the transport. It stores
no content, decides no group state, and sends no receipt. The decrypt
spends the ratchet step, so a plaintext that the application loses is
gone. UseprocessIncomingEnvelopeto keep the content until the
application completes the receive. -
Changed:
decryptGroupMessageis deprecated. Use
processIncomingEnvelope, which returns a receive outcome and keeps the
content until the application completes the receive.
decryptGroupMessagecompletes the receive before it returns, so a
plaintext that the application loses is gone. It throws
EncryptionErrorwithSTORAGE_QUOTA_EXCEEDEDwhen the receive queue
has no room, and withMESSAGE_DUPLICATEfor a message that this client
processed or that gives no content. -
Fixed: a retry deadline no longer resets a session whose resend
arrived. The commit of a resend now records its failed envelope as
fulfilled before the client acknowledges the resend. At start, the
client records it again for each stored resend before the retry request
lifecycle starts. Before, a resend that waited for its group could reach
the retry deadline, and the deadline reset the session. -
Changed: metadata values rest encrypted on the IndexedDB and key-value
stores. Each value thatsetMetadataandcompareAndSetMetadatawrite
now goes through the store's record cipher, AES-256-GCM. These values
include group master keys, the hosted device authentication key, and
outbox intents. The first open after the upgrade encrypts each clear value
that the store holds. A stop during that step is safe, and the next open
continues it. A store that is full keeps its clear values and still opens.
Metadata key names stay in clear, and some of them hold identifiers. The
SQLite stores need no change, because the file cipher covers them. An
earlier SDK version cannot read the encrypted values, so do not open the
store with one after the upgrade. -
Fixed: a membership change no longer makes the next distribution
identified. A member removal clears the group's endorsements. Before,
the next send resolved the sealed sender of each distribution pre-message
from the cache with no refresh. A pre-message to a member with no access
key then went identified inpreferredmode, or failed the send in
requiredmode. A group send now refreshes the endorsements for all
recipients before it seals the pre-messages, at preparation and again at
dispatch. A pre-message goes identified only to a member that the refresh
does not endorse. -
Fixed: an expired sender key no longer blocks group sends. When the
own sender key of a group is older thanmaxSenderKeyAge, a group send
rotates the key before it prepares the distribution pre-messages. The
rotation deletes the handoff rows of the group. The send then gives the
new key to each device that needs it, and encrypts with the new key. A
distribution that fails, for example after a Relay refusal or an offline
send, does not make a later send to the group fail with
SENDER_KEY_EXPIRED. -
Fixed: a retryable Relay refusal no longer ends a retry request.
The retry request scheduler now reads the Relay'sretryableflag before
the code and the HTTP status. A refusal withretryabletrue, for example
AUTHORITY_DENIEDwith HTTP 401 while a Relay binding is missing, is a
transport outage, or a rate refusal forQUOTA_EXCEEDED,MAILBOX_FULL,
and HTTP 429. The request waits and is posted again with the same bytes
and ID. Before, the request ended withrevoked-authority. A refusal with
retryablefalse keeps its terminal class. ATOKEN_INVALIDafter the
one token refresh isrevoked-authority. -
Fixed: a failed
onDeliveredhook no longer loses the delivery. The
client now marks a delivery source announced only after the hook returns.
Before, the client stored the mark first, so a hook that threw, a
stop()during the hook, or a crash lost the event. The hook now runs at
least once for each source. An error from the hook fails the Relay
receipt or the encrypted delivery receipt, so the relay delivers it again
and the hook runs again. Make the hook idempotent for one
clientMessageId,recipientDeviceId, andsource. -
Fixed: a failed
onRetryTerminalhook no longer marks the end
emitted. The SDK marks a retry end emitted only after the hook returns.
A hook that throws keeps the scheduler running. The next scheduler pass,
the next start, or the redelivery of the request runs the hook again.
The hook now runs at least once for each request. -
Added: a session reset when a retry request ends as
expired-content. Before the SDK callsonRetryTerminal, the
requester waits for each decrypt from the device that sent the failed
envelope that is in progress. It then archives its session with that
device, and its next send to the user reads the device list again. The
archive deletes the sender-key handoff rows of that device in every group.
The requester then deletes the rows of each device of that account in
every group, for a group message and for a pairwise message, as one
Signal client does at an automatic session reset. The reset does not
rotate a sender key. The next send to each group gives the current key to
each device of the account. The requester then sends one
null message to the device, at most one in an hour for each device. The
next ordinary message starts the new session when no null message goes.
The retry request record stores each step, so a replay after a restart
or a stop does not archive a newer session. The other terminal classes,
and a post that fails and is posted again, reset nothing.
onSessionArchivedruns for the archive. The event gets the optional
fieldrecoveryof the new typeRetryTerminalRecoveryEvent, which is
exported from the main entry and from/client. It has the device, the
sender key outcome, and the null message outcomesent,limited, or
failed. The sender key outcome isappliedwhen the failed envelope is
a group message and the requester has a sender key for that group.
Otherwise it isskipped. The rows of the account are deleted with each
outcome. -
Changed: a retry request is sealed when the client can seal it. The
client seals a retry request when it holds the access key of the target,
and the Relay does not see the requester. The seal holds the payload of
the identified request asPLAINTEXT_CONTENT(8), and the sealed post
keeps the retry ID, the client message ID and the timestamp, so the Relay
deduplicates it as before. A group send endorsement never seals a retry
request. A retry request follows thedeliveryModeofsealedSender, as
a delivery receipt does. In'preferred'mode the request goes identified
when the client has no access key or the Relay refuses it. In
'required'mode the request never goes identified: it stays pending, and
the scheduler posts it again until its deadline. In'disabled'mode it
is always identified. Before, each retry request was identified in every
mode, and an identified retry request does not have the privacy of a
sealed one. ThesendRetryRequestcallback ofprocessIncomingEnvelope
gets an identified request, so'required'mode never calls it. -
Changed: the client accepts a sealed retry request. A sealed envelope
with the content typePLAINTEXT_CONTENTgoes to the retry request
handler, never to a ratchet decrypt.processIncomingEnveloperejects a
sealed retry request withINVALID_CIPHERTEXT, as it rejects an
identified one. A11.0.xclient rejects this content type, so it drops
a sealed retry request from a newer client, and that request ends as
expired-contentfor the requester. A sealed retry request gets a
resend only from a sender device that runs this release or a later one. -
Changed: the
deliveryModedocumentation names what each mode
covers. The mode covers each message, resend, delivery receipt and
retry request to a device of another account. A typing indicator, and a
sync message to another device of the same account, are always
identified.onIdentifiedFallbackreports only a message or a resend. -
Changed: a group send seals its sender-key distributions. In
requiredandpreferredsealed-sender mode, each distribution
pre-message goes through the sealed route to its one recipient. The
recipient's access key comes first, and the group endorsement serves a
member without one. When the relay rejects the access key, the SDK
restricts the cached access mode of the recipient and posts the
distribution again with the group endorsement. Arequiredsend checks
this authorization before it encrypts a distribution and before any
post. When it cannot seal a distribution, it throws
SEALED_SENDER_REQUIREDand posts nothing. Apreferredsend posts a
distribution identified only after the relay rejects each sealed
authorization, and then callsonIdentifiedFallback.
A saved send whose distributions have no sealed transport seals them
from the stored ciphertext at replay, with no new ratchet effect. -
Fixed: a refused distribution is repaired once. When a device refuses
a distribution conclusively, for example after a reinstall with the same
device ID, the SDK refreshes the session of that device once and encrypts
the distribution again. It replaces the stored pre-message before the
post, so a replay never posts the refused ciphertext again. The repair
post has a new transport ID,<id>:repair:<n>. A replay after a restart
rebuilds a refused distribution from the current sender key with the same
senderKeyId. The repaired device then decrypts the later group messages
of the sender, but not the message of that send. When the sender key of
the send is no longer current, the replay refreshes the session, drops
the distribution, and continues. The next send gives the device the
current key. -
Fixed: a group send drops the distribution of a removed device. When
the relay refuses a distribution withNOT_FOUND, the SDK reads the
recipient's device list. If the list no longer shows the device, the
send drops that distribution and continues, as a direct send drops a
removed device. The send then makes no device post to that device. This
applies to the repair of a refused device message and to a device that the
re-read of a skipped account adds. A refused device that the list still
shows fails the send. -
Fixed: all six post kinds name the registration of their device. A
direct message, a sync copy, a sender-key distribution, a group device
message, a receipt, and a retry request send the registration ID of the
recipient session. This applies on the identified route, on the
access-key route, and on the group-token route. A post to a device with
no session names none. A sealed post names the registration of each device in its body,
and each hosted destination names the registration that the body gives
its device. A device with registration 0 names none. The Relay refuses a
device that was reinstalled with the same device ID. The refused post
then gets the one session repair, and the reinstalled device decrypts
the later messages. A group device post that the Relay refuses as a stale
device gets the same repair. The send gives the device the key of the
send and posts the device message again with the new registration. When
the repair of a refused distribution refreshes the session of a device,
that device leaves the shared group message of the send. The send posts
the device message to it on its own with the new registration, so the
device decrypts the message of that send. A device whose session changed
for another reason, such as a receipt that refreshed it, also leaves the
shared group message. When that device has no handoff row, it gets the key
of the send before its device post. On the attempt that prepared the send,
the device decrypts the message. On a replay, it gets the current key. It
recovers the message through its retry request only when the send was
Resendable and the send log kept it. For another send, the retry request
gets the current distribution alone, and the device does not get that
message. The group-token receipt names
each refused destination with its code. A destination that the Relay
refused asSTALE_DEVICEleaves the shared group message. The send
repairs that device and posts the device message to it on its own. When the Relay refuses a receipt or a retry
request as a stale device, the SDK fetches a fresh bundle, starts a new
session once, and posts the receipt or the request again under the new
session. The session start archives the stale session in the same record
write, and theonSessionArchivedhook runs after the start succeeds.
So a reinstalled device gets the retry request and resends the message.
A retry request ends with the terminal classmissing-senderonly when
the Relay has no bundle of the device or refuses the request again. When
the refresh fails for another reason, the receipt or the request is
posted again later. A failure before the session record write, such as a
failed bundle fetch, keeps the stale session, and the next stale refusal
refreshes again. A failure after that write, such as a failed SESAME
registration, leaves the new session, and the next post uses it. The sealed post sends
no sender identity.sendMultiRecipientUnidentifiedtakes nine arguments. -
Changed: a one-device sealed post can use the group endorsement on the
direct route. A sender-key distribution, a resend, or a group message
to one device whose only sealed authorization is the group endorsement
now posts to/anonymous/delivery/send. The Relay accepts the
endorsement there only when it is for the destination account alone.
The shared group message stays on/anonymous/groups/send, also for one
device. When the direct route answers the endorsement with 401
ANONYMOUS_AUTH_REJECTED, the SDK posts the same message once on the
group route for that device and writes oneinfolog line with the
behaviorGROUP_ROUTE_FALLBACK. A 401GROUP_CREDENTIAL_REJECTEDgets no
route fallback. Arequiredsend never falls back to identified
delivery. -
Fixed: a resend of a group send is sealed with the group endorsement.
A resend to a member with no access key could not be sealed, because the
resend did not look up the group of the original send. It now uses the
group endorsement of that group for the one device. Arequired
resend with no sealed authorization fails withSEALED_SENDER_REQUIRED
and makes no identified post. -
Fixed: a resend with only the sender key keeps the failed group message
open. When the sender logged no content for the device, it answers a
group retry request with the current sender-key distribution alone. That
resend gives the key but no content, so it no longer marks the failed
group message processed. A redelivery of that message now decrypts with
the key and gives its text. This applies, for example, when
receiveIncomingEnvelopesreports the message infailedMessageIdsand
the application does not acknowledge it. A resend that carries content
still marks the failed envelope processed. The client writes adebug
log line with the behaviorRETRY_SENDER_KEY_RESEND. -
Added:
SendResult.refusedDestinationCount. A group send through
the hosted group-token route reports the number of destinations that the
Relay refused when it accepted the shared post. A later fan-out refusal
is not counted. Other sends do not set the field. -
Added: send log and storage protection types.
SendLogStore
describes the storage of resendable group content: a payload for each
group send and a row for each recipient device, kept until that device
confirms the message or the entry expires.LocalStoreProtectionnames
how a local store encrypts its data and where its key is kept. A
SignalProtocolLocalSecretVaultcan declarecustody. A vault that does
not declare it counts assame-origin. -
Added: recovery constants.
RESENDABLE_CONTENT_RETENTION_MSis 14
days,RETRY_PLACEHOLDER_DEADLINE_MSis one hour, and
SEND_LOG_RESEND_JOB_TTL_MSis one day.MESSAGE_RECORD_TTL_MSis now
deprecated. It keeps the same 14-day value as
RESENDABLE_CONTENT_RETENTION_MS. -
Breaking: every local store must declare its protection.
SignalProtocolLocalStorenow requires aprotectionmember, so a store
that the application writes must add it. The SQLite stores report
file-cipherwith the custody of their vault. They read the cipher from
the setting that the database records, so a store with
encryptionAtRest: falsereportsnoneand no key.
KeyValueSignalProtocolStorereportsrecord-cipherwith the custody of
its vault.IndexedDbSignalProtocolStorereportsrecord-cipherwith
same-origincustody.InMemorySignalProtocolStorereportsnone. The
Expo SecureStore, react-native-keychain, and Electron safeStorage vaults
declareos-keychaincustody, and the IndexedDB vault declares
same-origin. -
Added: the SQLite stores keep a send log. The Node, Expo, React
Native, and web SQLite stores implementSendLogStore. Schema version 3
adds three tables for the payloads, the recipient rows, and the resend
jobs. The store opens a version 2 file and adds the tables with no change
to the existing data. The store clock setscreatedAt, so retention does
not read the client timestamp.clearAllKeysalso clears the send log. -
Breaking: every local store keeps a send log.
SignalProtocolLocalStorenow extendsSendLogStore, so a store that
the application writes must add the send log methods.
KeyValueSignalProtocolStore,IndexedDbSignalProtocolStore, and
InMemorySignalProtocolStorenow implement them with the same behavior
as the SQLite stores. The key-value and IndexedDB stores encrypt the
group, the content hint, and the content of each payload. The recipient
rows and the resend jobs hold no content and stay in the clear. The
IndexedDB schema version 8 adds three object stores and keeps the data of
a version 7 database.clearAllKeysand the key-value reset
also clear the send log. -
Breaking: resendable content needs a protected store. A sender
keeps the plaintext of a resendable message to answer a retry request.
protocol.contentRecoverynow decides whether the local store may hold
it. The default mode,require-protected, keeps it only when the store
is encrypted and its key is in an operating system keychain or a
declared application vault. The modeallow-co-located-keyalso keeps
it when the key shares the storage of the data, as with
IndexedDbSignalProtocolStoreand the IndexedDB vault. Those stores
encrypt each record, but a reader of the origin storage can also read
the key. The modeoffkeeps no content. A store with no encryption
never keeps it. When the policy refuses the store, the client keeps no
pairwise message record, and a retry request for a direct message resets
the session with no content. The client also deletes the message records
and the send log entries that the store already holds.
contentRecoveryStatusshows the decision and its reason, and the client
logs it once at creation. To keep content recovery on a web store, use a
keychain or application vault, or setallow-co-located-keyafter a
product review.retentionMssets the retention, at most 30 days. -
Breaking: the client validates the store protection. Creation fails
when the local store declares noprotection, an unknown value, or a
cipher without a key. A declaration adds no encryption. It must state
what the store does. -
Corrected: storage protection and content hint documentation. The
security model lists the protection that each built-in store declares,
and says that a deletion does not erase content. Received content is
outsideprotocol.contentRecovery: every store keeps it until the
application handles the envelope, and a later Relay receive deletes what
is older than 30 days.MessageRecordholds no retry count. ThecontentHintdocs give the retry request and
onDeliveryErrorbehavior of each hint. -
Added:
contentRecoveryStatus.groupMembershipSource. The sender
asks a group membership source before it gives group content to a device
again. A managed group is checked against its verified group state, from
groupswithresolveAciBytesByUserIds. The hook
protocol.contentRecovery.isCurrentMemberanswers for an ad hoc group
and gets the prefixed group ID. The status field ismanaged,hook, or
none, with the typeGroupMembershipSource. A client with no source
is still created and keeps content. It logs one warning at creation, a
direct message resend works, and the client refuses each group resend. -
Changed: a group message is resendable by default. When
protocol.contentRecoverykeeps content, a group send of aDataMessage
has the content hintResendableunless the application sets another
hint. The client writes the content and the recipient devices to the
send log before it stores the outgoing intent, so a failed write stops
the send before any post. The client writes the entry once. A replay of
an unfinished send writes no row again, so a row that an E2EE delivery
receipt deleted stays deleted. Each recipient gets the hint in the identified
envelope, the sealed message for one device, and the shared sealed
message for the group. When the policy keeps no content, or the send is
a string or bytes with aResendablehint, the send goes out as
Defaultand the client logs this once. -
Changed: an E2EE delivery receipt ends the content of its device.
When a device confirms a message with an E2EE delivery receipt, the
client deletes the send log row of that device. The last row takes the
content with it. A Relay delivery receipt, a read receipt, and a viewed
receipt delete no row, because a device also acknowledges a copy that it
could not decrypt. -
Added:
forgetSentContentandgetPendingRecipients.
client.getPendingRecipients(clientMessageId)lists the devices that
have not confirmed a group send with an E2EE delivery receipt.
client.forgetSentContent(clientMessageId)deletes the logged content of
one group send at once. It does not delete the pairwise message records. -
Changed: content retention follows
retentionMs. The client deletes
send log entries and pairwise message records past
protocol.contentRecovery.retentionMsat creation, atstop(), and
before the first resendable group send of each day. It also deletes
expired send log entries before each lookup. A retry request for a
pairwise message older than the retention gets no content. Before, the
client used the fixed 14-day value for the message records. -
Changed: the store clock sets the age of a pairwise message record.
Each local store setscreatedAtof aMessageRecordfrom its clock at
the first write, and keeps that value when a resend replaces the record.
It does not store thecreatedAtthat the caller gives. Retention, the
sweep, and the sender refusal of an old retry request measure from this
value, so the message timestamp does not change retention. A retry
request for a message with no stored content takes its age from the
failed timestamp. Records written before this change keep their stored
createdAt. A customMessageRecordStoremust setcreatedAtthe same
way. -
Breaking:
KeyStorageOpsneedsclearSendLog. A device lifecycle
reset now clears the send log with the other local crypto state. A
customKeyStorageOpsmust addclearSendLog(): Promise<number>. The
built-in stores already implement it. -
Added: a retry request names the failed client message ID.
RetryRequesthas an optionalfailedClientMessageId. When the relay of
the requester delivers the client message ID of the failed envelope, the
retry request carries it, so a sender can find the message when the
relay assigns its own envelope ID. The payload stays at version 1, and a
decoder that does not know the key ignores it. Each later attempt of the
request carries the ID of the failed envelope, not the ID of a failed
resend. -
Changed: a retry request for a sender-key message has no ratchet key.
The SDK no longer reads a ratchet key from the ciphertext of a failed
sender-key message. A retry request withoutratchetKeynames a
sender-key failure. -
Fixed: a pairwise retry request carries the ratchet key of the
sender. The SDK reads the ratchet key from the stored wire form of the
failed message. A stale-session request now resets the session, and a
request for a current session resends only. Before, the SDK read the
wire form as a framed message, so no request carried a key. A sealed or
malformed message still gives no key. -
Fixed: a session reset for a retry request sends its null message.
When the content of a failed pairwise message is gone, and the request
names the ratchet key of the current session, the sender archives that
session. The sender then starts a new session from a fresh prekey bundle
of the requesting device and sends the null message on it. Before, the
null message found no session, so the sender did not acknowledge the
request. A request with another ratchet key, for another device, or from
another sender does not change the session. -
Fixed: a new session from a prekey bundle keeps the earlier sessions.
A session that the client starts from a prekey bundle archives the
current session and keeps the archived sessions. Before, it replaced the
session record, so a message that the peer encrypted on an earlier
session did not decrypt. After a session reset for a retry request, a
message that the requester sent on the archived session now decrypts. -
Changed: a sender answers a group retry request from the send log.
The sender looks up the send log rows of the device that asked: by the
failed client message ID, then by the failed envelope ID, then by the
failed timestamp. Each logged entry that it finds goes again to that
device only, with its timestamp and content hint, and only when the
current membership source admits the requester to the group of the
entry. A row counts its resends, and the count survives a restart. A row
with 5 resends gets no more. A requester that is not a current member,
and a group with no membership source, get no content and no
distribution. The answer to the failed envelope then ends with the new
RetryTerminalClassvaluemembership-refused. The sender stores the
end, callsonRetryTerminalonce, and answers no later request for that
envelope from that device. The refusal is not a delivery error, and the
sender acknowledges the request envelope. A group with no membership
source also writes onewarnlog line for the group with the behavior
RETRY_GROUP_RESEND_REFUSED. This deviates from the recovery design,
which logged the refusal only. When no row names the
message, a sender-key failure gets the current sender-key distribution
of the group alone, so the device decrypts the later group messages of
the sender. A retry request without a ratchet key never resets the
pairwise session. A retry request that names another device of the
sender gets no answer, and the client writes awarnlog line with the
behaviorRETRY_REQUEST_OTHER_DEVICE. -
Changed: a resend of logged group content carries the current sender
key. Each logged entry goes again with the current sender-key
distribution of its group beside the content, in one pairwise envelope.
The sender reads the distribution when it makes the resend, so the
device also decrypts the later group messages of the sender. With no
sender key for the group, the content goes alone. Theinfolog line of
the resend says whether it carried a distribution. -
Breaking: a content adapter must put content beside a distribution.
SignalProtocolContentAdapter.serializeContentBesideSenderKeyDistribution(content, groupId, distribution)
is a new required method. It returns one content with the logged content
and the distribution. The default adapter adds the distribution to the
JSON object of the content, in the form of
serializeSenderKeyDistributionText. It throws when the content is not
a JSON object. -
Added: a resend of logged content survives a restart. Before the
membership check, the sender writes a pending resend job for each logged
entry that a retry request attempt names. The job of the entry that
answers the attempt names the failed envelope in the new field
SendLogResendJob.failedEnvelopeId. The SQLite send log keeps it in the
new columnfailed_envelope_idofsend_log_resend_job. A job counts
each post attempt before the post and ends aspostedafter it. The job
of an entry that the membership or the resend limit refuses ends as
abandoned. The client runs the pending jobs atcreate(), at each
relay transition to connected, and after a failed job. The wait after a
failure is a full-jitter wait from 1 s that doubles up to 30 s, and a
failed job writes awarnlog line with the behavior
RESEND_JOB_RETRY. A job checks the membership again when it runs, and
never resets a session: it makes one only when there is none. A job
ends asabandoned, with oneinfolog line, when it is older than
SEND_LOG_RESEND_JOB_TTL_MS, when its logged entry is gone, when the
answer to its failed envelope ended, when the membership refuses the
requester, at the resend limit, or when no session can be made. A
request without a request attempt writes no job. This deviates from the
recovery design, which named asend-log:resend-jobreliability record.
The job lives in the send log store, with its entry. -
Changed: a message record keeps no sender-key distribution. The
message records of the pairwise sends keep no chain key. A send of a
distribution alone keeps no record. A record of a distribution beside
other content keeps only that content, so it stays resendable. A
sent-sync transcript to an own device follows the same rule. At
create(), the client removes each distribution from the records that
an earlier version wrote, once, and then writes the marker
migration:skdm-records-removed. It keeps the content and the
createdAtof a mixed record and deletes a record of a distribution
alone. When this sweep fails,create()fails, and the next start
sweeps again. The retry handler applies the same rule to each record
that it reads. A group timestamp whose record it deletes gets the
current sender-key distribution of the group, as a missing record does. -
Changed: a start with content recovery off clears content once. When
content recovery is off,create()clears the message records and the
send log once, and then writes the markerresendable-content:cleared.
A later start with content recovery off clears nothing, until a start
with content recovery on keeps content again. The clear deletes no
sender-key state, so the next group send needs no new distribution.
When the clear fails,create()fails, and the next start clears again. -
Breaking: a content adapter must remove a distribution from a
record.SignalProtocolContentAdapter.withoutSenderKeyDistribution(plaintext)
is a new required method. It returns the plaintext unchanged when it
carries no distribution, the other content when a distribution travels
beside it, andnullfor a distribution alone. It applies the same
rule to the content inside a sent-sync transcript of the adapter's
format. The default adapter implements it. -
Breaking: a custom message record store must rewrite its records.
MessageRecordStore.rewriteMessageRecords(rewrite)is a new required
method. It callsrewritefor each record, replaces the plaintext of a
record when the result differs and keeps itscreatedAt, deletes the
record when the result isnull, and returns the number of changed
records. The memory, key-value, web, and SQLite stores implement it. -
Fixed: content beside a sender-key distribution reaches the
application. Before, the client installed a distribution and then
dropped the rest of its content. A data message in the same envelope
never reachedonMessageDecrypted, and no delivery receipt went out for
it. Now the client installs the distribution, then routes the other
content as a receipt, a typing indicator, or a data message. This
applies on the relay subscription and onreceiveIncomingEnvelopes().
A distribution that comes alone still gives nothing to the application. -
Breaking: a content adapter must decide a distribution-only content.
InspectedSignalProtocolContent.senderKeyDistributionOnlyis a new
requiredboolean. A custom content adapter must set it on every
inspect result. It istruewhen the content carries a sender-key
distribution and nothing else. The default adapter sets it. -
Changed: content beside a sender-key distribution is group content.
Before the client installs such a distribution, it checks that the
sender may send messages to the group of the distribution. When the
group refuses the sender, the client installs no key and gives no
content.onMessageDecryptedgets the content withisGroupset to
trueand the group ID asconversationId. A retry resend can carry
the content of a failed group message beside the current sender key.
The application then gets that content under themessageIdof the
failed message, on the relay subscription and on
receiveIncomingEnvelopes(). The resend marks the failed message
processed, so a later redelivery of that message is dropped. When the
failed message decrypts first, the later resend is dropped. -
Fixed:
processIncomingEnvelope()asks for a failed group message
again. A group message that fails with a retryable error, for example
after a lost sender-key distribution, now gets a retry request to its
sender, and the client acknowledges it, as on the relay subscription.
Before, the method threw with no request, so the message was lost. A
group message with the Implicit content hint gets no request and is
acknowledged. On every receive path, the stored request names the
message as a group message, so a resend that brings only the sender key
does not mark the message processed. -
Added: the
onDeliveryErrorandonDeliveryRecoveredhooks. When a
received message fails to decrypt and the client asks the sender for it
again,onDeliveryErrortells the application that the content did not
come. The content hint of the message sets the time. A message with the
Default hint, or with no hint, gets the error at once. A Resendable
message gets it one hour after the first failure,
RETRY_PLACEHOLDER_DEADLINE_MS, when its content has not come. An
Implicit message never gets it. A request that a rate limit refuses, or
a request that ends, gets the error at once. It deletes nothing, and the
retry request continues after it. When the content comes after the
error,onDeliveryRecoveredruns with the receive time of the first
failure. Each hook runs at least once for each failed message, also
across a restart. The client marks the error shown, or the recovery,
only after the hook returns. A hook that throws, a client stop, or a
crash before that mark runs the hook again at the next retry request
pass, start, or redelivery. A hook that throws in a retry request pass
does not delay the next attempt or the end of the request. A receive
does not acknowledge a failed message whose error or recovery hook threw
before the content, but an error from the recovery hook after the
content does not fail the receive that gave it. Make each hook
idempotent for onefailedEnvelopeId. The events are
DeliveryErrorEventandDeliveryRecoveredEvent, with the cause type
DeliveryErrorCause. They are exported from the main entry and from
/client. For a failed group message, both events carrygroupId, in
the form ofDecryptedEnvelope.conversationId, when the client holds
the sender key that the message names. The error of a message with a
missing sender key has nogroupId. Its recovery has one when the
decrypt that gave
the content installed or used the sender key of the group. The client checks the time at each redelivery of the failed message and at each retry
request write. With a relay, it also checks atcreate()and, while the
client runs, at the time when the error is due. A pairwise message has
no content hint by default, so its error comes at once. -
Changed: a receive failure that no resend can repair asks for
nothing. Three failures are in this class: the group state refuses the
sender, the identity of the sender is not trusted (UNTRUSTED_IDENTITY),
and the envelope is malformed (INVALID_CIPHERTEXT). On the relay
subscription, onreceiveIncomingEnvelopes(), and on
processIncomingEnvelope(), the client posts no retry request, stores
no request record, and acknowledges the envelope.onDecryptionError
runs once for the envelope, and a redelivery of it is dropped. The relay
subscription writes anerrorlog line that names the refusal. A
ciphertext that does not decode or parse now fails with
INVALID_CIPHERTEXT. Before, it got a retry request. Before, the relay subscription also asked again for a
refused or untrusted envelope, andprocessIncomingEnvelope()did not
acknowledge it. -
Changed: a retry request ends 14 days after the first failure. The
deadline of a request is the time of attempt 1 plus 14 days, the default
retention of resendable content,RESENDABLE_CONTENT_RETENTION_MS.
Each later attempt keeps it, and the timestamp of the
failed message does not change it. Before, the deadline was 24 hours
after attempt 1 and no later than 30 days after the failed message. A
request that a rate limit refuses waits for the end of the rate window
and keeps its deadline, also across a restart. At the deadline the
request ends asexpired-content, also when its stored attempt is
answered. A request whose content came does not end. A stored request
keeps its stored deadline. At the deadline, a sender key that has not
rotated since the failure is older than the defaultmaxSenderKeyAgeof
14 days. After anexpired-contentend of a group message, the next
group send then rotates that key, as any send does, and distributes the
new key to each member. -
Added: the
attempts-exhaustedretry terminal class. When the
resend that answers the fifth attempt of a request,
MAX_RETRY_RESPONSES_PER_MESSAGE, also fails, the request ends as
attempts-exhaustedand posts no sixth attempt.onRetryTerminalruns
with the class, andonDeliveryErrorruns with the cause
request-ended. Only anexpired-contentend resets the session, so
this end resets nothing. A switch overRetryTerminalClassmust handle
the new value.