Overview
whatsapp-rust implements the Signal Protocol for end-to-end encryption of both one-on-one and group messages. The implementation is based on Signal’s libsignal library, adapted for WhatsApp’s specific protocol requirements.Architecture
The Signal Protocol implementation is split across two main locations:wacore/libsignal/- Platform-agnostic Signal Protocol core (Rust port of libsignal)src/store/signal*.rs- WhatsApp-specific storage integration with Diesel/SQLite
Key Components
Double ratchet protocol
The Double Ratchet algorithm provides forward secrecy and post-compromise security for 1:1 messages.Session Initialization
Two participants initialize a session using Diffie-Hellman key exchange:- Compute shared secrets from ephemeral key exchanges
- Derive root key and chain key using HKDF-SHA256:
- Initialize sender and receiver chains
wacore/libsignal/src/protocol/ratchet.rs:41-172
Message Encryption
Each message advances the sender chain and derives ephemeral message keys:- Load current session state
- Get sender chain key and derive message keys:
- Encrypt plaintext with AES-256-CBC:
- Create SignalMessage with MAC for authentication
- Advance chain key and save session state
- SignalMessage: Standard encrypted message
- PreKeySignalMessage: Includes prekey bundle for session establishment
Plaintext padding. Before encryption, the serialized
wa::Message is padded with a uniform-random number of bytes in 1..=16 (the pad length is repeated as the byte value, matching WA Web’s rand % 16 + 1 and whatsmeow). v0.6 fixed a prior scheme that masked the length with & 0x0F, which skewed the distribution toward 15 and could never emit 16 — a subtle fingerprinting divergence from the official client. The receiver strips the padding by reading the final byte as the length.Message Decryption
Decryption handles out-of-order delivery and tries multiple session states:- Try current session state first
- If MAC verification fails, try previous (archived) sessions
- Derive/retrieve message keys for the counter
- Verify MAC:
- Decrypt with AES-256-CBC
- Promote successful session to current if needed
The implementation optimizes memory by using take/restore patterns to avoid cloning session states during decryption attempts (see
session_cipher.rs:495-619).Chain key ratcheting
Message keys are derived from chain keys, which advance with each message:wacore/libsignal/src/protocol/ratchet/keys.rs
Chain key overflow protection
The chain key index is au32 that increments with each message. Without overflow protection, the index could silently wrap past u32::MAX (4,294,967,295) back to 0, creating a counter reuse vulnerability that breaks cryptographic guarantees (nonce reuse in message key derivation).
Both 1:1 and group chain keys use checked_add() to return a typed error instead of wrapping:
wacore/libsignal/src/protocol/ratchet/keys.rs, wacore/libsignal/src/protocol/sender_keys.rs
Forward Jumps
The protocol tolerates out-of-order messages up to a limit. Peer sessions and group sender-key chains match WhatsApp Web’ssignalFutureMessagesMax; a pairwise session with one of your own other devices gets a wider (but still bounded) ceiling, since multi-device app-sync legitimately jumps far ahead and the peer is trusted:
Before this change, self-device sessions were exempt from the limit entirely (jumps beyond
MAX_FORWARD_JUMPS were logged and allowed through). MAX_FORWARD_JUMPS_SELF (25,000) now bounds that path too — still wide enough for legitimate app-sync catch-up, but no longer unbounded. Peer sessions and group sender-key chains dropped from 25,000 to 2,000, matching WA Web’s signalFutureMessagesMax; a message whose counter is more than 2,000 steps ahead is rejected (driving the retry-receipt path) instead of forcing thousands of KDF derivations per message.wacore/libsignal/src/protocol/consts.rs, wacore/libsignal/src/protocol/session_cipher.rs
DM device fanout
When sending a direct message, the library resolves all known devices for both the recipient and your own account, then encrypts two different plaintexts for two categories of devices:- Recipient devices receive the actual message content
- Own other devices (your other linked devices) receive a
DeviceSentMessagewrapper containing the message plus the destination JID, so your other devices can display the sent message in the correct chat
Destination JID encoding via
DsmDestination (#1137). The DeviceSentMessage wrapper writes its destination JID as a length-prefixed protobuf field, which needs the encoded length before the bytes. wacore::messages::MessageUtils::encode_dm_plaintexts and dm_plaintexts_from_encoded used to take destination_jid: &str, so the caller rendered the Jid into a String purely to measure and copy it. Both now take impl DsmDestination instead. DsmDestination is implemented directly on Jid, so it can measure and write its own wire form without an intermediate String. It’s also implemented on str and the standard string wrappers (String, Box<str>, Rc<str>, Arc<str>, Cow<str>), carried through references of any depth via two blanket impls. The DM send path now passes to_jid: &Jid directly instead of &to_jid.to_string().Device resolution
The DM send path builds the full device list in a WA Web-compliant manner (matchingWAWebSendUserMsgJob and WAWebDBDeviceListFanout):
- Local registry first — the client checks the local device registry via
get_devices_from_registry()for both the recipient and own account. A network fetch (get_user_devices) is only triggered on a cache miss, avoiding unnecessary LID-migration side effects. - Hosted device filtering — devices flagged as hosted (via
is_hosted()) are filtered out, matching WA Web’sDBDeviceListFanoutexclusion. - Sender device exclusion — the exact sender device is removed from the list so
ensure_e2e_sessionsnever creates a self-session. This matches WA Web’sisMeDevicecheck ingetFanOutList. - Self-DM deduplication — when sending to your own account, the recipient and own device lists overlap. A
HashSet-based dedup pass (matching WA Web’sMapkeyed bytoString) removes duplicates.
Per-recipient memoization (#1118). The steps above — the registry lookups, the list rebuild, the partition, and the phash — are memoized per recipient in
dm_devices_memo, keyed by the resolved wire JID and validated against the device-topology generation and the sending identity (own PN/LID). A warm repeat send to the same chat reuses the stored ResolvedDmDevices (an Arc, so a hit is a refcount bump — nothing is cloned) instead of redoing this resolution. Any device add/remove/replace, registry invalidation, PN↔LID mapping change, or re-pair invalidates the entry through the same topology tracker the existing group_devices_memo already uses; a resolution that had to fall back (e.g. a registry lookup miss) is never memoized. Explicitly requesting network-fresh data bypasses the memo entirely. See dm_devices_memo and group_devices_memo in memory_report().Device partitioning
Thepartition_dm_devices function classifies all resolved devices into recipient and own groups, and excludes the exact sender device (the current device) entirely. It partitions all_devices in place — swapping recipient devices to the front of the passed-in Vec — instead of allocating two new device vectors per send:
partition_dm_devices runs once per dm_devices_memo entry — wrapped by ResolvedDmDevices::new(all_devices, own_jid, own_lid) — rather than on every send; see DmStanzaRequest and ResolvedDmDevices below.
Sender device exclusion
The exact sender device is identified by matching both the user and device ID against your phone number JID (PN) or your Linked Identity JID (LID):Own device recognition
After excluding the sender device, the remaining devices are classified usingmatches_user_or_lid, which checks if a device JID belongs to the same user as either your PN or LID:
DeviceSentMessage plaintext — not the recipient plaintext. Without LID matching, your own LID-based devices would be misclassified as recipient devices, causing them to receive the wrong message format.
Both PN-based and LID-based devices must be checked because WhatsApp’s multi-device architecture uses both addressing schemes. A user’s devices may appear under either their phone number JID (
@s.whatsapp.net) or their Linked Identity JID (@lid), depending on the device type and registration path.DmStanzaRequest and ResolvedDmDevices
prepare_dm_stanza takes a DmStanzaRequest whose devices field is the already-resolved, already-partitioned fan-out for the recipient — a borrowed &ResolvedDmDevices — rather than a raw device list:
ResolvedDmDevices (wacore::send::ResolvedDmDevices) wraps the partitioned device set from partition_dm_devices together with a lazily memoized phash and, since #1396, a lazily memoized Signal addressing — both cached per entry in dm_devices_memo:
Memoized Signal addressing (#1396). A warm DM used to resolve each device’s PN→LID mapping three separate times on its way out — once in the session preflight, once building the lock keys, once in the encrypt fan-out — each a cache lookup plus a
Jid clone, with the lock keys re-sorted every send. DmSignalAddressing (wacore::send::DmSignalAddressing) now holds both results — encryption(), the per-device Signal address parallel to devices(), and lock_keys(), that same list already sorted and deduplicated in lock order — filled on the first send of a dm_devices_memo entry and served to every later one via signal_addressing(). Invalidation comes for free from the entry’s own contract: a mapping learned for any member of the fan-out bumps the topology generation the entry is stamped with, so the entry — and this memo with it — is rebuilt rather than served stale. See “DM per-device locking” under Single-allocation session lock keys below for where the resolution and the lock-key sort now happen only once.Session preflight and the 406 case
Beforeprepare_dm_stanza builds the stanza, ensure_e2e_sessions runs as a preflight that fetches prekeys for any resolved device without an established Signal session — one IQ covering up to SESSION_CHECK_BATCH_SIZE (50) devices. The server can reject devices two ways: by name, returning a <user> node whose bundle is replaced with an <error code="406"> for that one device, or batch-wide, failing the IQ itself with a 406 that names nobody.
Skips the PN→LID lookup when the caller already resolved it (#1396).
ensure_sessions_for_devices (wacore::send::encrypt) is now a thin wrapper over ensure_sessions_for_devices_resolved(..., signal_addresses: Option<&[Jid]>). Passing None reproduces the old per-device SendContextResolver::get_lid_for_phone lookup; the DM path instead passes Some(addressing.encryption()) from the memoized DmSignalAddressing (see DmStanzaRequest and ResolvedDmDevices above), so the preflight indexes straight into the pre-resolved address instead of hitting the mapping cache again. A signal_addresses list whose length doesn’t match devices is treated as not supplied — the fallback is per-device resolution in every build, not a debug-only assertion. The group path, which has no such memo, still calls ensure_sessions_for_devices with None and is unaffected.A named
406 rejection now refreshes only that device’s user, not the whole batch (#1153, closes #1143). PreKeyUtils::parse_prekeys_response returns a PreKeyFetchOutcome { bundles, rejected } instead of a bare bundle map. rejected carries the devices the server named directly in the response body, each with the <error> code it was rejected with. When one of those codes is 406, the preflight calls invalidate_device_caches_for with the named device JIDs. That call dedupes the JIDs to their distinct users and refreshes each user’s whole device-list cache entry, since the cache is keyed per user rather than per individual device. The send proceeds afterward, and every device that did return a bundle is unaffected — so one rejected device no longer costs the rest of the batch.This corrects the premise of #1139 (closed #1135). #1139 assumed the response “doesn’t say which one” and so invalidated every distinct user in the batch on any 406. WA Web’s own parser (FetchKeyBundlesUserError in WAWeb/Fetch/PrekeysJob) already reads this per-device error. whatsapp-rust’s parser fed the same node to the bundle parser instead: it failed on the missing fields, logged the failure as a malformed bundle, and discarded the code. The device was skipped, but its stale entry was never refreshed, so the next send resolved the same absent device again. #1139’s whole-batch invalidation and propagated ? error still apply to the one case a named rejection cannot cover: a 406 on the IQ itself.The asymmetry #1139 described between the DM and group paths is gone — DM handled at the preflight, group already covered by the per-device fan-out (stale_device_users). SendContextResolver::fetch_prekeys_for_identity_check now returns wacore::prekeys::PreKeyFetchOutcome instead of HashMap<Jid, PreKeyBundle>. If you implement a custom SendContextResolver, update this method’s return type to match. The new type carries a named rejection into the group fan-out’s EncryptResult::rejected_devices, so stale_users_for refreshes exactly those users instead of inferring staleness from whichever targets went unencrypted for unrelated reasons (missing bundle, malformed bundle, failed session setup). Both paths now act on the same named-device signal when the server provides it.“The send proceeds afterward” no longer holds when the named device is a primary (#1362). The description above still applies in full to a named 406 on a companion device. When the named device is a primary (device 0) — the recipient’s or the sender’s own — the preflight now fails the fetch instead of refreshing and continuing with zero established sessions for it: see SendError::PrimaryDeviceRejected.Concurrent preflights for the same address now coalesce into one fetch (#1315). The old pre-fetch existence probe in
ensure_e2e_sessions couldn’t deduplicate a burst of concurrent callers for the same protocol address (user + device). It answers before the IQ goes out, so every caller in the burst reads the same “no session” and fetches prekeys independently. Each returned bundle is then installed over the last. A burst could therefore leave several mutually incompatible session states behind — none matching the ratchet key the peer was actually encrypting under — and burn one of the peer’s one-time prekeys per redundant fetch.Client now holds an EnsureRegistry that claims an address synchronously, before the first await. This splits a batch into addresses this call owns and addresses another in-flight call already claimed. The claiming caller (the “leader”) fetches and installs as before; every other caller waits on it instead of fetching again. Release happens on Drop, so a cancelled or panicking leader still wakes its waiters. A waiter whose leader failed re-probes for itself, rather than reporting a session that was never established. This mirrors WA Web’s own ensureE2ESessions wid-to-promise map in WAWeb/Manage/E2ESessionsJob.js — with one difference: a waiter doesn’t inherit its leader’s error directly, because ErrorChainExt inspects our errors and anyhow::Error isn’t Clone.In-flight addresses are exposed as ensure_inflight on Client::memory_report(). It’s normally zero, and holds an address only for the span of one prekey fetch.PreparedDmStanza
prepare_dm_stanza returns a PreparedDmStanza struct containing the stanza node and the locally computed phash for server ACK validation:
phash changed from Option<String> to Option<CompactString> (wacore_binary::CompactString) in #1118, matching the type ResolvedDmDevices::phash() returns: a warm memo hit clones the cached CompactString inline instead of allocating a new String.MessageUtils::participant_list_hash(). Unlike group messages, the DM phash is not sent on the wire — WA Web only includes phash in the DeviceSentMessage for groups. The DM phash is used purely for local validation against the server’s ACK to detect device-list drift.
The
DeviceSentMessage.phash field is set to None for DMs, matching WA Web’s behavior where only group DeviceSentMessage wrappers include a phash. The DM phash is computed and tracked separately by the caller.wacore/src/send.rs:675-820, src/send.rs
PN→LID session migration
WhatsApp’s multi-device architecture uses two addressing schemes: phone number JIDs (PN,@s.whatsapp.net) and Linked Identity JIDs (LID, @lid). WhatsApp Web always resolves PN→LID before any session operation via createSignalAddress(). whatsapp-rust mirrors this behavior — when a LID mapping is discovered for a phone number, any Signal sessions stored under the PN address are automatically migrated to the corresponding LID address.
The automatic migration described below is also exposed for manual invocation:
Signal::migrate_sessions(from, to) runs the same move for a caller-chosen JID pair, and Signal::session_info(jid) inspects a session (migrating a legacy PN-addressed one first if needed) without mutating it further. See the Signal API reference for both.Signal address resolution
Client::resolve_encryption_jid() mirrors WA Web’s SignalAddress.toString() (WAWeb/Signal/Address.js). It upgrades the JID’s server to its LID counterpart when a mapping is known, and otherwise returns the input unchanged:
The
device, agent, and integrator fields always round-trip — only the user (replaced with the LID user) and server change. This keeps Cloud API / Meta Business hosted devices on a hosted-flavored LID address rather than collapsing them into the standard @lid server, matching WA Web’s per-device session keying.
resolve_encryption_jid() upgrades PN → LID unconditionally whenever a mapping is known — it governs Signal session addressing only, matching WA Web’s SignalAddress.toString(). The outbound DM wire namespace (the stanza to, <participants>, and DeviceSentMessage destination) is a separate, account-level decision — see DM wire namespace vs. Signal session addressing below.DM wire namespace vs. Signal session addressing
Since v0.6.x (fix for #941), a DM’s outer<message to> / <participants> addressing is no longer derived directly from resolve_encryption_jid(). Some accounts are not yet 1:1-LID-migrated on WhatsApp’s servers, and those accounts get every LID-addressed DM rejected with ack error="400" even though the underlying Signal session is correctly LID-keyed.
Client::resolve_dm_wire_jid() (src/client/lid_pn.rs) makes this account-level decision, mirroring WA Web’s Lid1X1MigrationUtils.isLidMigrated() / WAWebMessageDestinationChat:
- Migrated account (
Client::is_lid_migrated()istrue): behaves exactly like before — the wire namespace upgrades PN → LID whenever a mapping is known. - Unmigrated account: DMs stay on PN even with a cached LID mapping. A caller-supplied LID with a known PN mapping is mapped back to the PN chat; a LID with no cached mapping is sent as-is (there is no reverse LID→PN network resolution, matching WA Web).
Client::is_lid_migrated() is true when either is true:
- The persisted
Device.lid_migratedflag (see Storage — DeviceStore), set once from the primary’spair-success<client-props>(isChatDbLidMigrated) or from alid_migration_mapping_sync_messageprotocol message pushed to the primary’s own companions (self-only — see Authentication — one-to-one LID migration state). - The
lid_one_on_one_migration_enabledab prop, as a fallback for sessions paired before the flag existed. The first observation of this prop being on also latches the persisted flag, so the account doesn’t flap back to PN addressing before the next props fetch.
lid_migrated never reverts for the same account — only pairing a different account onto the same store resets it. Signal session addressing (resolve_encryption_jid) and inbound decrypt are unaffected by any of this; only the outbound DM wire namespace is gated.
Why migration is needed
After pairing, the primary phone may initially establish sessions under a PN address. Once the LID mapping becomes known (from usync, incoming messages, or device notifications), the phone begins sending from the LID address. Without migration, the client holds a session under the PN address but receives messages addressed to the LID — causingSessionNotFound decryption failures.
Proactive migration at LID discovery
When a new LID-PN mapping is learned (viaadd_lid_pn_mapping), the client scans devices 0–99 for PN-keyed sessions and migrates them. All reads and writes go through the SignalStoreCache rather than the backend directly — this prevents reading stale data when the cache has unflushed mutations (e.g., after SKDM encryption ratcheted the session). The migrated state is flushed to the backend at the end so it survives restarts.
Identity keys are migrated independently of sessions — they can outlive deleted sessions and survive session re-establishment.
add_lid_pn_mapping also has a batch form, Client::add_lid_pn_mappings(mappings, source), which durably records many LID↔PN pairs in one call and runs the same per-mapping migration as the single-entry path. It returns how many mappings were actually written, deduplicated against existing records.On-the-fly migration during decryption
If a message arrives from a LID address and decryption fails withSessionNotFound or InvalidPreKeyId, the client attempts PN→LID migration as a fallback before requesting a retry:
- Look up the PN for the sender’s LID
- Attempt to migrate PN sessions to LID via the signal cache (same cache-first logic as proactive migration)
- Retry decryption with the migrated session (already in the cache — no reload needed)
- If
DuplicateMessageoccurs during post-migration retry, it is silently ignored - Fall back to retry receipt only if migration does not resolve the issue
InvalidPreKeyId case occurs when a PreKeyMessage references a consumed one-time prekey, but the session actually exists under a PN address (legacy migration). Migrating the session lets Signal use the existing ratchet state instead of looking up the consumed prekey. This migration is attempted in both the identity-change retry path and the initial decryption path.
This ensures existing databases are fixed without requiring re-pairing.
Login-time session check
At login, the client checks the session state of own device 0 (primary phone):- LID session exists — no action needed
- PN session only — logged; migration deferred to first message via on-the-fly path
- No session — will be established on first message exchange
Both migration paths route through the
SignalStoreCache, ensuring they see the latest in-memory state. The proactive migration runs when a LID mapping is first discovered and flushes to the backend afterward. The on-the-fly migration handles the case where the database already contains stale PN sessions from before the mapping was known.src/client/lid_pn.rs, src/client/sessions.rs, src/message.rs
Sender keys (group encryption)
Groups use the Sender Key protocol for efficient multi-recipient encryption.Sender key address normalization
Sender key records are keyed by a compositeSenderKeyName containing the group JID and a sender protocol address string. WhatsApp delivers group stanzas with inconsistent sender addressing — the pkmsg (which carries the SKDM) arrives with a device-qualified participant JID (e.g., 100000000000001.1:75@lid), while the skmsg (the actual encrypted group message) arrives with a bare participant JID (e.g., 100000000000001.1@lid).
Without normalization, the sender key would be stored under the device-qualified address during SKDM processing but looked up under the bare address during skmsg decryption, causing NoSenderKeyState failures.
The client normalizes the sender JID to its bare form using to_non_ad() (which strips the device component, setting device = 0, agent = 0) at every point where a SenderKeyName is constructed. The SenderKeyName::from_jid() convenience method handles the to_string() conversion automatically:
SenderKeyName::from_jid() is equivalent to SenderKeyName::new(group_jid.to_string(), sender_address.to_string()) but avoids the manual to_string() calls and is the preferred constructor.
This ensures the cache key is always in the form "{group}:{bare_user}@{server}.0", regardless of whether the original stanza used a device-qualified or bare JID.
Location: src/message.rs, wacore/libsignal/src/store/sender_key_name.rs, wacore/binary/src/jid.rs (to_non_ad())
Sender key distribution
Each participant generates and distributes a sender key:- Chain ID: Random 31-bit identifier for this sender key session
- Iteration: Message counter (starts at 0)
- Chain Key: 32-byte seed for deriving message keys
- Signing Key: Ed25519 public key for message authentication
Group Encryption
Messages are encrypted with the sender’s current chain key:- Load sender key state for the group
- Derive message keys from current chain key
- Encrypt with AES-256-CBC
- Sign message with Ed25519 private key
- Advance chain key
Group Decryption
Recipients decrypt using the sender’s distributed key:group_decrypt copies skm_bytes into an owned Bytes and forwards to group_decrypt_shared, which does the actual parsing and decryption:
Bytes — as the receive path does, slicing it straight out of the frame buffer — call group_decrypt_shared directly and skip that copy: SenderKeyMessage parses in place and keeps a reference-counted slice of your buffer as its serialized storage (via SenderKeyMessage: TryFrom<Bytes>) instead of allocating its own copy, the same technique SignalMessage already uses.
Process:
- Parse SenderKeyMessage
- Look up sender key state by chain ID
- Verify Ed25519 signature
- Derive message keys for iteration (handling out-of-order)
- Decrypt with AES-256-CBC into a plaintext buffer sized exactly to the ciphertext length
Unknown device detection
During group message decryption, the client checks whether the sender’s device is present in the local device registry viais_from_known_device(). This detection triggers in two places within the group message processing path:
- After successful
skmsgdecrypt — if the sender device is not in the registry, the decrypted message is still processed and delivered normally. Signal decryption success already proves the sender holds a valid session key, so discarding the message would only add latency via an unnecessary retry round-trip. A background device sync is triggered to update the local device registry. - After a
NoSenderKeyStateerror — if the sender device is unknown, the retry reason is upgraded fromNoSessiontoUnknownCompanionNoPrekey
- Online: the client immediately invalidates the cached device registry for the user and fires a background usync request to refresh the device list
- Offline (during offline sync): the unknown device’s user JID is batched into a
PendingDeviceSyncset, which is flushed after offline sync completes (see Deferred device sync)
src/message.rs, src/client/device_registry.rs, src/pending_device_sync.rs
Retry receipt from unknown group device
When the client receives a retry receipt,handle_retry_receipt checks whether the requesting device is present in the local device registry. Previously the handler dropped all retries from unregistered devices — this was safe for WA Web because WA Web keeps participant device lists fresh via a pre-send sync, so any legitimate requester is already known before the send.
For a library client, a participant device can legitimately be absent from the local registry: if the device joined between the last device-list sync and the group send, it will have received the skmsg from the server but never obtained a sender key, causing it to retry indefinitely. The retry receipt may carry a <keys> bundle — the ADV-signed device-identity, the identity key, a one-time prekey, and the signed prekey — which is everything needed to establish a Signal session and resend. But a newly-linked device that has no bundle still retries forever if the client only drops it: the reconciliation that fires when a prekey fetch returns 406 never triggers for that device because it was never in the send set.
Whenever a retry arrives from an unknown device, handle_retry_receipt now calls schedule_unknown_device_sync before consulting should_drop_unknown_device_retry. This treats the retry as a staleness signal: the requester’s user JID is enqueued for a device-list resync (deduplicated via PendingDeviceSync, so a retry storm from a single device cannot fan out into a usync storm). Once the resync completes, the device appears in the registry and future sends include it in the sender-key distribution — the retries stop. This mirrors WA Web’s syncDeviceListJob trigger on the retry path.
The drop predicate still controls whether the current retry is recovered or dropped:
When the bundle includes a
<device-identity>, process_retry_key_bundle validates the ADV chain against the requester’s account key (using the stored primary identity as a fallback when the server omits account_signature_key). A present-but-invalid ADV result is a hard error; the session is not built. If <device-identity> is absent from the bundle, or if no account key can be found, the check is skipped with a warning and the session is built anyway — matching the behaviour of the regular prekey-fetch path. The drop predicate only gates on syntactic <keys> presence, so the ADV guarantee is conditional on the bundle including a well-formed <device-identity>. This mirrors whatsmeow’s approach of building the prekey session directly from the receipt bundle without a device-registry gate.
Location: src/retry.rs, wacore/src/protocol/retry.rs, src/pending_device_sync.rs
Immutable sender key loading
TheSenderKeyStore trait’s load_sender_key method takes &self (not &mut self), allowing sender key lookups to proceed under a read lock. This is safe because loading a sender key is a pure read operation — no state is mutated. The store_sender_key method still requires &mut self since it modifies state.
This means concurrent group decryptions for different senders can load sender keys in parallel without contention, while writes (SKDM processing) still serialize correctly.
If you implement
SenderKeyStore for a custom backend, load_sender_key must use &self (immutable reference). Implementations that previously required &mut self for internal caching should use interior mutability (e.g., Mutex or RwLock) instead.Sender key existence check
Before distributing sender keys, the group message path checks whether the local sender key already exists. This check uses theSignalStoreCache with a read lock (get_sender_key()), matching the status broadcast path. This avoids acquiring a write lock and prevents unnecessary SKDM re-distribution on every group send.
Per-device sender key tracking
To avoid resending Sender Key Distribution Messages on every group message, the client tracks sender key distribution status per device for each group. This uses a unifiedsender_key_devices table (see Storage - ProtocolStore) that matches WhatsApp Web’s participant.senderKey Map<deviceJid, boolean> model — a single boolean per device per group indicating whether that device has a valid sender key (true) or needs fresh SKDM distribution (false).
The tracking update is deferred until after the server acknowledges the message stanza. This matches WhatsApp Web’s behavior where markHasSenderKey() is only called after the server confirms receipt.
Why deferred? If the tracking were updated immediately after building the stanza (but before sending), a network failure between stanza build and send would leave stale entries — devices would be marked as having the sender key when they never actually received it. Subsequent messages would skip SKDM for those devices, causing decryption failures.
PreparedGroupStanza return value:
prepare_group_stanza returns a PreparedGroupStanza struct containing the stanza node and a skdm_devices: Vec<Jid> field: the devices this send reports as holding the sender key, so the post-ACK warm mark below can use it directly. This is the distribution target set, not literally “devices that received SKDM” — it marks the whole list a send meant to key (matching WA Web markHasSenderKey(x, M)), including an external participant’s companion device whose SKDM encryption failed, so a transient per-device failure doesn’t force a re-fanout on every later send. (Your own companion devices are a separate case — see the has_key=true exclusion under “Own devices are never marked has_key=true” below.) It eliminates the need for callers to re-resolve devices after sending, closing a race window where the device list could change between stanza preparation and post-ACK tracking update.
A primary device for which local SKDM encryption produced no node is excluded from
skdm_devices, as of #1328. This covers a local failure only — a failed pre-key fetch, a session-setup error — not a node that was sent but never reached or was never processed by the recipient; the sender has no signal for that case, and it still relies on a retry receipt same as before. The whole-target-set marking above is unchanged for an external participant’s companion device — matching getKeyDistributionMsg’s isPrimaryDevice gate, WA Web can never reach that marking with a failed primary in the set at all, because a primary’s encryption failure rejects the entire send outright. This SDK’s best-effort send carries on instead of failing the group over one member, so it enforces the same guarantee directly: retain_reportable_sender_key_devices filters a primary with no local SKDM node out of skdm_devices before it reaches the warm mark. Reporting such a primary as warm would hide that member’s whole user behind the incremental-targeting diff above until that member’s own retry receipt corrects it — in the field report that motivated this fix, a closed group’s low message volume meant that correction could take a long time to arrive. The same PR keeps a companion the usync response cannot validate (no signedKeyIndexBytes) from dropping its primary out of the resolved participant set entirely, at the device-list projection layer — see USync.- Group path: After
send_node()succeeds, the caller uses theskdm_deviceslist fromPreparedGroupStanzato callset_sender_key_status(group, devices, true). No re-resolution needed. - Status path: A late-init boolean tracks whether full distribution occurred. The sender key tracking is only updated after the status stanza is successfully sent.
- Error recovery: If
prepare_group_stanzafails withNoSenderKeyState, all sender key device tracking for that group is cleared and the send is retried with full distribution. - Sender key rotation: On
rotateKey, the Signal sender key is also deleted for forward secrecy (matching WhatsApp Web’sdeleteGroupSenderKeyInfo), and all device tracking is cleared viareset_sender_key_device_tracking— a DB-first clear with a cold-mark fallback (see below). - Group
<modify>notification (number/LID migration): Aw:gp2<modify>notification (a participant’s number or LID changed) unconditionally force-rotates the own group sender key and invalidates both the persisted and in-memory group metadata cache, matching WhatsApp Web’smodifyParticipantInfo(rotateKey: true). The next send regenerates and redistributes a fresh sender key against the current participant list instead of risking a stale entry for the migrated device. SeeClient::force_rotate_own_sender_key,src/handlers/notification/groups.rs. - Admin revoke: When you send an admin revoke (
RevokeType::Admin), the client treats it as an ordinary group message for distribution purposes. It uses the same incremental-targeting diff described above, own-device exception included, instead of a message-type-specific override (#1278). Earlier versions forced full redistribution on every admin revoke, on the mistaken assumption that the server required it. On a warm group, this could turn a small revoke payload into one<enc>node per device. A cold group (no cached key, or mid-rotation) still distributes to every device, same as a cold ordinary send.
- Loads the per-device sender key map — first checking the in-memory cache, falling back to the database via
get_sender_key_devices - Resolves all current group participant devices
- Computes the diff — only devices with
has_key=falseor not yet tracked receive the SKDM - Passes the targeted device list to
prepare_group_stanzavia theskdm_target_devicesparameter
On the first group send (or any send where the cached map is empty), the filter still runs unconditionally — every resolved participant device is treated as
has_key=false and receives the SKDM. This matches WhatsApp Web, which iterates an empty senderKey Map as false per participant. There is no early-exit for an empty cache; otherwise the very first message after a fresh start would skip distribution entirely.Own devices are never marked
has_key=true (#999). The post-ACK warm mark excludes the account’s own companion devices, matching WhatsApp Web’s !isMeDevice guard on markHasSenderKey. They therefore never leave the “not yet tracked” bucket above and are re-included as SKDM targets on every send — see the follow-up note under “Parallelized group encrypt fan-out” for why. External group members are unaffected: a successful distribution still marks them warm.src/send.rs, src/client/sender_keys.rs, wacore/src/send.rs
Parallelized group encrypt fan-out
The group send path no longer serializes encryption behind a client-level lock.prepare_group_stanza and encrypt_for_devices now take an explicit &runtime handle (&*self.runtime) so per-device encryption can run on runtime::blocking() tasks concurrently. Combined with the move to update_device_lists (batched device-registry writes) and a no-lock IdentityAdapter::is_trusted_identity stub, group fan-out scales with the runtime’s worker count rather than with a single critical section.
This is an internal performance change — no public method on Client::send_message was renamed, and the order of <to> children in the resulting stanza is unchanged. If you implemented a custom SignalStore, note that update_device_lists(records: Vec<DeviceListRecord>) is now part of the trait so the fan-out can batch its writes.
While per-device encryption runs concurrently, the sender-key chain is protected by two separate locks per
(group, sender) pair:-
Session-setup lock (
SenderKeyStore::session_setup_lock) — held only acrossensure_sessions_for_devices(prekey fetch + X3DH). May span network I/O. Warm sends (no SKDM needed) never take it, so they are never blocked by a cold send’s network round-trip. -
Chain lock (
SenderKeyStore::sender_key_lock) — held across SKDM creation + pairwise encrypt fan-out +skmsgencrypt. Pure CPU; never spans network I/O. This is the invariant that prevents two concurrent sends from splitting the key between the SKDM and theskmsg.
encrypt_for_devices is composed of two public halves: ensure_sessions_for_devices (network, returns SessionPlan) and encrypt_for_devices_with_sessions (CPU, consumes SessionPlan). The DM path calls encrypt_for_devices unchanged; the group path calls them separately with the chain lock taken only around the second.Per-device session lock around the SKDM fan-out (v0.6). The chain lock above only serializes the sender-key chain — it does not cover the pairwise Signal sessions that
encrypt_for_devices_with_sessions mutates for each SKDM target device. Those are the same pairwise sessions the DM path locks (see “DM per-device locking” under Single-allocation session lock keys below) via session_lock_for() / session_guards_for(). Before #990, the group fan-out held only the chain lock, a disjoint key, so a concurrent DM (or another group send) sharing a device could race that device’s pairwise ratchet — both sides load chain index N and both store N+1, silently dropping one advance. When the lost advance carried the SKDM, that member never received the sender key and every subsequent skmsg stayed undecryptable for it until a retry re-distributed.prepare_group_stanza now acquires the SKDM targets’ per-device session locks through SendContextResolver::lock_device_sessions() before taking the chain lock, and releases them right after the fan-out — the skmsg chain encrypt that follows only touches the sender-key chain, never a pairwise session. The Client implementation of this hook reuses build_session_lock_keys() + session_guards_for(), so the group and DM paths serialize on the exact same mutexes, in the same sorted order, and always acquire session locks before the chain lock — no path takes the reverse order, so this cannot deadlock. The hook defaults to a no-op, so a custom SendContextResolver (as used in tests and benches) is unaffected unless it opts in.Session-setup failures are isolated per device (v0.6).
ensure_sessions_for_devices used to abort with Err the moment process_prekey_bundle failed for any one target device. Since prepare_group_stanza gates the entire SKDM fan-out on session_plan.is_some(), one device’s X3DH failure nulled the plan and every device in the cohort — not just the failing one — got no SKDM, even though the skmsg still shipped and the phash covered the full set. An external member recovers via a retry receipt, but an own companion’s retry hits mark_forget_sender_key with exclude_own_devices=true, which filters own-user JIDs and returns early — so that companion stayed has_key=true forever and couldn’t decrypt the group from that device until an unrelated full rotation (participant removal or PN↔LID migration).As of #996, a device whose session setup fails is logged and skipped rather than aborting the plan — matching WhatsApp Web’s GroupKeyDistributionMsg, which wraps each device’s ensureE2ESessions in its own try/catch and drops only the failing one. The sessionless device is then naturally excluded by the encrypt fan-out (which already skips devices without a session), so every other device still receives its pairwise SKDM.#996 closed the primary harm — an unrelated device’s setup failure no longer suppresses the whole cohort’s SKDM. A narrower window remained: the warm mark (update_sender_key_devices, called after the server ACK) recorded the full distribution target as has_key=true, including our own companion devices, regardless of whether each one’s pairwise SKDM encryption actually succeeded. Since the forget path (mark_forget_sender_key) excludes own devices for the reason above, an own companion whose one SKDM encryption failed — or that was warm-marked without ever receiving a node — was marked warm and could never be un-marked: a permanent orphan until an unrelated full rotation. External devices didn’t have this problem; they recover through the retry-receipt forget path.#999 closes this residual by excluding own devices from the warm mark too (exclude_own_devices=true), mirroring WhatsApp Web’s ParticipantStore helper, which guards both markHasSenderKey and markForgetSenderKey with the same !isMeDevice check. Own companions are therefore never memoized as has_key=true — filter_skdm_targets (“Per-device sender key tracking → Incremental targeting” above) always re-includes them, so they get a fresh SKDM on every group send. This is a deliberate trade-off (a few extra pairwise SKDM nodes per send when the account has companions) in exchange for making the orphan impossible. External devices are unaffected: a successful distribution still marks them warm, and the retry-receipt path still repairs any that go stale.The group distribution lane now guards the full audit-reset-redistribute sequence, not just the SKDM fan-out (#1043). Previously
Client::group_distribution_lock() (see “Parallelized group encrypt fan-out” above) was taken only around the cold SKDM send itself. Sender-key deletion (participant-removal rotation, forced own-key rotation), per-device tracker resets, and the status-broadcast distribution path could run concurrently with that lock held elsewhere, letting an encrypt racing a rotation restore a retired key after deletion, or a tracker reset race stale delivery marks back onto a new chain.rotate_sender_key_on_participant_remove, force_rotate_own_sender_key (now taking &Jid instead of a pre-stringified group ID), warm group sends, status sends, phash-mismatch recovery, and periodic sender-key rotation all now hold the same per-group lane across their own-key delete/reset and the following redistribution. A rotation that arrives while a send is mid-fan-out waits for the lane instead of deleting the chain state out from under it; a send that arrives mid-rotation waits for the rotation to finish before re-auditing device state. Held lanes are never capacity-evicted, so a live rotation or fan-out cannot be silently dropped from the map mid-operation (see group_distribution_locks_capacity in the Cache Configuration reference).Sender-key tracker resets are DB-first (#1043).
reset_sender_key_device_tracking replaces the old direct clear_sender_key_devices + cache-invalidate call at every rotation and redistribution site. It clears the per-device tracking row-by-row in the database first, and only invalidates the in-memory SenderKeyDeviceCache after that durable clear succeeds. If the DB clear fails, every existing tracked row is instead marked cold (has_key=false) as a fallback so the next send still re-distributes; if that fallback write also fails, the operation returns an error and the send stays fail-closed rather than risking a stale has_key=true row surviving onto a freshly rotated chain.The unknown-participant rotation in retry receipt handling is a special case: handle_retry_receipt deletes the own sender key and resets tracking for a <receipt type="retry"> from an unrecognized group participant, then must still fall through to the per-chat resend rate limiter and other throttles further down the same function. The signal cache is now explicitly flushed right after the rotation — before any later throttle can return early — so a rotation is never left un-persisted by an unrelated early exit later in the same call.Observability: distribution-lane pressure is exposed on
memory_report() (#1043). Client::memory_report() now reports group_distribution_locks (live lane count), group_distribution_lock_evictions (cumulative cold evictions), and group_distribution_lock_eviction_blocks (cumulative evictions skipped because the lane was live) — see memory_report(). These update only under capacity pressure and add no allocation or per-message cost below the soft cap.In-memory sender key device cache
TheSenderKeyDeviceCache provides an in-memory caching layer over the per-device sender key tracking data stored in the database. Without this cache, every group send would require a database round-trip to load the sender key device map — the cache eliminates that overhead after the first load for each group.
- Time-to-idle eviction: The cache uses TTI semantics (default: 1 hour, 500 entries), so entries for inactive groups are automatically evicted while frequently-used groups stay cached
- Pre-parsed, pre-indexed maps: Database rows are parsed into a
SenderKeyDeviceMapstruct that provides O(1) lookups by user and device ID, avoiding per-query string parsing - Single-flight initialization: The
get_or_initmethod usesPortableCache’s single-flightget_with— if multiple concurrent group sends for the same group trigger a cache miss simultaneously, only one database read executes and all callers share the result - Explicit invalidation: The cache is invalidated when sender key state changes (rotation, error recovery, retry failures) so stale data is never served
SenderKeyDeviceMap structure:
The SenderKeyDeviceMap pre-parses JID strings from the database into a user-to-devices HashMap for efficient lookup:
You can tune the cache capacity and TTI via the
sender_key_devices_cache field in CacheConfig.
Location: src/sender_key_device_cache.rs, src/send.rs
Phash validation for stale device list detection
When sending group, status, or DM messages, the library validates the participant hash (phash) returned in the server’s acknowledgment against the locally computed phash. A mismatch indicates that the server’s view of participant devices differs from the client’s — meaning the local device list is stale.
How it works:
- Before sending, the client obtains the locally computed
phash— from the stanzaphashattribute for group/status messages, or fromPreparedDmStanza.phashfor DMs - A
PhashWaiter(expected hash, target JID, whether to also invalidate the group cache) is registered for the message ID viaregister_phash_waiter— a map entry, not a channel or a task - The message stanza is sent to the server
- When the server’s ack for that message ID arrives, the read loop compares its
phashattribute against the expected value inline, with no task involved - On a match the entry is just dropped; on a mismatch the client spawns a task to invalidate caches — so a send only pays for a task in the uncommon case, not on every send (#1116)
Group sends did not register a phash waiter at all until #1328.
prepare_group_stanza always computed and attached the phash to the outgoing stanza, but the field the send path used to carry a phash back out for ack comparison was hardcoded to None on the group branch — so step 4 above only ever ran for DM and status sends. A group whose participant device set diverged from the server’s had no way to detect it short of a member’s own retry receipt, which in a closed group never arrives on its own: nothing else there generates inbound traffic. PreparedGroupStanza now carries the stanza’s phash, and the group send path forwards it the same way the DM branch already did.A disagreeing group phash no longer resets sender-key tracking (#1328). Status broadcasts still clear their sender-key tracking on mismatch, falling back to deleting the bot’s own sender key if the clear fails — but a group takes neither arm, dropping only its cached metadata. This mirrors WA Web’s
resendGroupMsg, whose group branch is sendQueryGroup alone: no markForgetSenderKey, no device-table write. Resetting a whole group’s sender-key tracker on every mismatch would cost a full SKDM fan-out per message for as long as the divergence lasted — a bigger regression than the mismatch itself. The re-queried metadata is what lets the next send resolve participants fresh instead.syncDeviceListJob([recipient, me])). On mismatch, the client invalidates the device registry cache for both the recipient’s user JID and your own phone number (PN) JID, ensuring the next send re-fetches the current device list for both parties.
A DM phash mismatch now repairs the message itself, not just the caches (#1362). After invalidating the device registry, the client re-resolves the recipient’s device list with a forced refresh. It compares the refreshed list against the set the original stanza actually covered (
PhashWaiter::dm_devices, an Arc<ResolvedDmDevices> shared with the send’s own memo entry — a refcount bump, not a copy). Any device the refreshed list holds and the original send did not is retransmitted the message pairwise, under the original message id — the same shape as WA Web’s resendUserMsg job with an excludeList of devices that already have a copy. Each of these devices receives the message for the first time; a device the original stanza already covered is left untouched, so this never delivers a duplicate to the same device. This only ever adds devices: a device the refresh dropped already received its copy and needs nothing further. The retransmission is direct (no group-metadata lookup) and registers no phash waiter of its own, so it cannot loop.The phash check never blocks the send path. If the server’s ack never arrives, nothing polls the waiter directly — it is swept out on the keepalive tick, and is guaranteed to survive the sweep immediately following its registration (so a waiter is never dropped mid-flight) but is removed on the sweep after that. Since each keepalive tick lands 15–30 seconds after the last, the actual time-to-live is roughly one to two tick intervals — about 15 to 60 seconds, depending on where registration falls relative to the sweep cycle — rather than the old fixed 10-second timeout. The sweep runs before keepalive’s own idle early-return, so a connection with steady inbound traffic — which skips sending pings — still gets its stale waiters cleared; a stranded waiter would otherwise read as an outstanding IQ and suppress pings for the life of the connection. This matches WhatsApp Web’s approach of using phash as a best-effort staleness detector rather than a hard requirement.
WA Web phash parity (v0.6)
Two corrections aligned the group phash with WA Web’sphashV2:
- Full device set, every send. The group phash is now computed over the complete resolved participant device set plus the sending device on every send — not just the devices that received an SKDM in that stanza. Warm sends (which distribute no new SKDM) now pass the full resolved set via the
all_devices_for_phashparameter toprepare_group_stanza, so the phash matches the server’s view even when the SKDM target set is empty. Status broadcasts keep their prior phash behavior. - Standard base64 alphabet. The phash now encodes with the standard base64 alphabet (
+//) instead of URL-safe (-/_), matching WA Web and whatsmeow.
not-modified (304) when membership is unchanged — saving a full metadata round-trip.
Location: src/send.rs, src/client.rs
Cryptographic Primitives
AES-256-CBC (message content)
Used for encrypting message bodies in both 1:1 and group messages:wacore/libsignal/src/crypto/aes_cbc.rs
Thread-Local Buffers
The implementation uses thread-local buffers to reduce allocations:wacore/libsignal/src/protocol/session_cipher.rs:14-54
HKDF-SHA256
Used for key derivation in session initialization:wacore/libsignal/src/protocol/ratchet.rs:18-39
X25519 key agreement
calculate_agreement runs during session setup, and again whenever the session performs a DH ratchet step — when an incoming message carries a remote ratchet key the local session hasn’t chained on yet. get_or_create_chain_key calls RootKey::create_chain once to derive the new receiving chain; DeferredSenderRatchet::apply then calls it a second time, with a freshly generated local key, to derive the new sending chain. Each create_chain call performs exactly one agreement, so a single DH ratchet step costs two. Messages within an already-open chain advance via ChainKey::step_with_message_keys instead, which costs none.
wacore/libsignal/src/core/curve.rs
As of PR #1218, calculate_agreement routes through SignalCryptoProvider::x25519_agreement. AES-256-CBC above uses the same pluggable crypto-provider hook; HKDF-SHA256 does not — it always calls hkdf::Hkdf directly. Override the hook with set_crypto_provider (wacore/libsignal/src/crypto/provider.rs) to run the agreement on another backend. The default is this crate’s own implementation, and it cannot fail.
If you install a backend that can refuse the operation, you see the refusal as CurveError::AgreementFailed. Through SignalProtocolError, it reaches you as KeyAgreementFailed. The decrypt path treats a refusal as a local failure, not message corruption: it takes priority over the MAC-based verdicts (InvalidMessage, BadMac).
Call
set_crypto_provider before any crypto call, key agreement included. The provider installs once — a call made after the default provider has already initialized returns an error instead of replacing it.PreKey Management
Pre-keys enable asynchronous session establishment in the Signal Protocol. whatsapp-rust manages pre-key generation and upload to match WhatsApp Web’s behavior.Configuration
The per-batch upload count is configurable through the builder/factory API (default812, matching WhatsApp Web’s UPLOAD_KEYS_COUNT). The upload-trigger threshold is a private constant.
[5, 65_535] are clamped at upload time (an out-of-range value logs a warn!). The floor avoids an empty-but-flagged pool or a re-upload loop. The ceiling is the wire-format limit: the upload IQ encodes the pre-key list length as a u16, so a larger batch would generate keys locally and then fail to encode.
Per-key X25519 generation and protobuf encoding for the batch are offloaded via wacore::runtime::blocking (runtime-agnostic; runs inline on wasm) since the caller-controlled batch size can be large.
Pre-key ID counter and wrap-around
Pre-key IDs use a persistent monotonic counter (Device::next_pre_key_id) that only increases, matching WhatsApp Web’s NEXT_PK_ID pattern:
If the counter wraps while unconsumed high-ID pre-keys still exist in the store, the database upsert (
ON CONFLICT DO UPDATE) silently overwrites them. This is an accepted trade-off because the server consumes keys well before a full 16M cycle completes.src/prekeys.rs
Retry-receipt prekey marking
When building a retry receipt that includes keys (should_include_keys), the one-time prekey handed directly to the peer is now also marked uploaded via mark_single_prekey_uploaded, matching WhatsApp Web’s markKeyAsUploaded. Without this, the same prekey ID could be re-offered to the server pool in the next batch upload — a third party fetching the bundle could then consume the identical one-time ID and fail to decrypt.
mark_single_prekey_uploaded requires a held prekey_upload_lock guard (a compile-time proof, not just a runtime convention) so the get-or-gen and the watermark write are atomic against the batch upload path. It only advances first_unupload_pre_key_id when the id being marked is still the current window head (idempotent no-op otherwise), and collapses next_pre_key_id onto the wrapped low watermark only when marking the terminal id at the 24-bit edge — a non-terminal high-end head keeps its surviving window key.
The device account is validated before the prekey is reserved/marked, so a missing account fails the retry-receipt build without silently abandoning a one-time prekey from the upload window.
Location: src/prekeys.rs, src/retry.rs
Force-refreshing pre-keys for device migration
When migrating a device from an external source (e.g., a Baileys session into anInMemoryBackend), the server may still hold pre-key IDs whose private key material you cannot reconstruct. Any pkmsg referencing those IDs will fail permanently with InvalidPreKeyId.
The public refresh_pre_keys() method force-uploads a fresh batch of Client::wanted_pre_key_count() pre-keys (default 812; tunable via with_wanted_pre_key_count / set_wanted_pre_key_count), giving the server new IDs the caller has locally. Old unmatched IDs drain naturally as peers consume them.
prekey_upload_lock to prevent races with the count-based and digest-repair upload paths, then calls upload_pre_keys_with_retry(force: true) which uses Fibonacci backoff (1s, 2s, 3s, 5s, 8s, … capped at 610s).
Two related public methods build on the same prekey_upload_lock-guarded path:
Client::refresh_pre_keys_with_count(count)— same force-upload asrefresh_pre_keys(), but with a caller-chosen batch size instead of the configuredwanted_pre_key_count.Client::ensure_pre_keys()— a non-forced check-and-top-up: uploads only if the server-side pool is below the low-water mark, rather than unconditionally replacing it.
src/prekeys.rs:263-266
Digest key validation
After connection, the client validates that the server’s copy of the key bundle matches local keys. This matches WhatsApp Web’sWAWebDigestKeyJob.digestKey() flow.
Wire format:
- Query the server for the key bundle digest via
DigestKeyBundleSpec - If the server returns 404 (no record), trigger a full pre-key re-upload
- If the server returns 406/503 or other errors, log and skip
- On success, compare registration IDs
- Load each pre-key referenced by the server and extract its public key
- Compute a local SHA-1 digest over: identity public key + signed pre-key public + signed pre-key signature + all pre-key public keys
- Compare the local hash against the server-provided hash
The
<list> node contains <id> children (not <key> children). The parser iterates all children of <list> without tag filtering, matching WhatsApp Web’s mapChildren behavior which does not filter by tag name.Client::validate_digest_key() is a public method — callers can trigger this validation pass on demand instead of only relying on the automatic post-connection check.src/prekeys.rs:218-344, wacore/src/iq/prekeys.rs:170-302
Signed pre-key rotation (RotateKeyJob)
The signed pre-key minted at pairing was otherwise permanent — a forward-secrecy gap. whatsapp-rust mirrors WhatsApp Web’sRotateKeyJob: on a cadence, generate a fresh signed pre-key, upload it, and retain the previous keys so prekey messages already in flight against a rotated-out key still decrypt.
Cadence:
- Checked once per connection. Spawned right after the startup pre-key upload during post-login init, so a slow or failing rotation IQ never delays the rest of login.
- Checked again on a ~6-hour keepalive maintenance tick for as long as the connection stays up (whatsapp-rust#1411). Before this, the connect-time check was the rotation’s only caller, so a process that paired once and never reconnected could hold a connection open past the 27-day cadence and never rotate at all. The keepalive pass re-checks
connection_generationbefore rotating — a pass still queued against a connection that has since been retired (a reconnect landed first) no-ops instead of uploading against stale session state. - Rotates once
now - last_signed_pre_key_rotation_ms >= SIGNED_PRE_KEY_ROTATION_INTERVAL_MS(27 days). This matches the interval WA Web’sROTATE_KEYtask returns to its scheduler (WAWebTasksDefinitions, confirmed against a fresh captured WA Web bundle) and is not configurable — there is no A/B property behind the value, so a per-client override would only widen the public API for a number the official client hardcodes identically.rotate_signed_pre_key()remains the escape hatch for forcing a rotation out of band. - A device upgraded in with the field at
0gets a one-time baseline stamp (DeviceCommand::SetSignedPreKeyRotationBaseline) instead of rotating immediately, so its first rotation lands a full interval out. - Single-flighted via
Client::signed_pre_key_rotation_lockso overlapping post-login tasks (from reconnect churn) can’t run the rotate/upload/prune sequence concurrently; a losing task just no-ops for that check.
Prior to whatsapp-rust#1237 the interval was 7 days. Production logs showed every observed
InvalidSignedPreKeyId decrypt failure falling inside the 21-day decrypt window (SIGNED_PRE_KEY_RETENTION * interval) that cadence produced — the shortest window of WA Web, zapo, and whatsapp-rust despite whatsapp-rust retaining three keys where zapo retains one. Widening the interval to 27 days quadruples the window to 81 days at unchanged storage cost (the three retained records cost 555 bytes regardless of cadence) and a quarter of the rotation IQs, device-state writes, and keypair generations per year.SIGNED_PRE_KEY_RETENTION stays at 3 (the current key plus the 2 most recent rotated-out keys). WA Web never prunes signed pre-keys at all — the private key stays on disk forever, which buys interop at the cost of the exact forward-secrecy property rotation exists to provide. whatsapp-rust keeps the bound: a key remains addressable for SIGNED_PRE_KEY_RETENTION * SIGNED_PRE_KEY_ROTATION_INTERVAL_MS (81 days) counted from when it was minted — that is, for the 2 rotation cycles (54 days) after it is first rotated out — after which its private key is destroyed and any prekey message still naming it gets a retry rather than a decrypt.
Wire format (RotateSignedPreKeySpec, reuses the upload path’s <skey> encoder so the two can never drift):
Client::rotate_signed_pre_key):
- Compute
new_id = current_id + 1, wrapping to1at the 24-bit border (same scheme as one-time pre-key IDs). - Stage the new key pair in the
signed_prekeysbackend table before upload — an already-staged candidate for that id is reused verbatim, so a retry after an ambiguous failure re-uploads the exact key the server may have already accepted instead of minting a different one under the same id. - Retain the outgoing (current) key in the backend table before upload, so once the server accepts the new key the old id’s decrypt window is already durable — no post-acceptance write can strand it.
- Upload via the
<rotate>IQ above. - On success,
DeviceCommand::SetSignedPreKeyatomically installs the new key pair, id, signature, and rotation timestamp. The now-redundant staged copy is dropped, and retained signed pre-keys are pruned (newest-id-first) toSIGNED_PRE_KEY_RETENTION(3 total: the current key + the 2 most recent rotated-out keys).
RotateKeyJob ladder; a rotation failure never fails login):
The retry delay is expressed as a backdated cadence timestamp (
rotation_timestamp_after_failed_upload, persisted via the same DeviceCommand::SetSignedPreKeyRotationBaseline command used for baseline seeding) rather than a second field, so should_rotate_signed_pre_key still reads a single timestamp. Only the cadence-driven caller (maybe_rotate_signed_pre_key) writes this schedule on failure — a manual rotate_signed_pre_key() call leaves the cadence untouched on failure either way, so a forced rotation can’t drag an unrelated automatic rotation forward or backward.
Backend fallback for rotated-out ids:
Before this feature, Device::load_signed_prekey (src/store/signal.rs) returned a record only when the requested id matched the current signed_pre_key_id field — the signed_prekeys backend table (which already existed, with full CRUD) was never consulted for other ids. Rotating the key in place would therefore make any in-flight prekey message naming the old id fail with InvalidSignedPreKeyId. load_signed_prekey and contains_signed_prekey now fall back to the backend table for non-current ids, which is what makes rotation safe to ship.
Retry, not NACK, once the id ages past retention: A sender’s PreKeySignalMessage can still name a signed pre-key id that has since aged past SIGNED_PRE_KEY_RETENTION (3 total: current + 2 rotated-out) — the backend fallback above has nothing left to return, and InvalidSignedPreKeyId is the correct, permanent answer. On the 1:1 decrypt path (src/message/receive.rs), this now routes to a retry receipt (RetryReason::InvalidKeyId) carrying the current bundle, mirroring the sibling InvalidPreKeyId arm — instead of falling through to the catch-all UnhandledError nack, which would drop the stanza from the offline queue and lose the 1:1 message permanently and silently.
SignedPreKeyAdapter::get_signed_pre_key (src/store/signal_adapter.rs) logs a warning naming both the requested id and the current device id whenever neither the device field nor the backend fallback resolves an id, so the two ways this can happen — a peer holding a bundle older than the retention window, versus a peer naming an id we never minted — stay distinguishable from the log alone. InvalidSignedPreKeyId itself carries no payload, so this is the only place either id is recorded.Client::rotate_signed_pre_key() is a public method — callers can force an out-of-cadence rotation directly instead of waiting for the 27-day check. It shares signed_pre_key_rotation_lock with the automatic path (so a manual call can’t race a background rotation) and propagates upload failures to the caller rather than swallowing them.src/features/rotate_key.rs, src/store/signal.rs, src/store/signal_adapter.rs, src/message/receive.rs, wacore/src/iq/prekeys.rs, wacore/src/store/commands.rs
Re-pair pre-key healing (v0.6)
If the user re-pairs the device (for example by re-scanning the QR code), the server discards its copy of our pre-key bundle even thoughDevice::server_has_prekeys may still read true from the previous pairing. v0.6 resets server_has_prekeys = false immediately after a successful re-pair so the next connect uploads a fresh batch instead of trusting the stale flag.
The lock-acquisition for the digest-key validator also moved into validate_digest_key itself. Previously the caller held prekey_upload_lock before calling the validator, which would deadlock when validation hit a 404 and tried to acquire the same lock to perform the re-upload. The lock now wraps only the re-upload path, so the 404→re-upload transition completes without contention.
Location: src/handlers/notification.rs, src/pair.rs, src/prekeys.rs
ADV companion identity validation
When fetching a pre-key bundle for a contact’s companion device (WhatsApp Web / Desktop), the bundle’s<device-identity> element is validated to confirm that the fetched identity key is cryptographically bound to the account. This guards against a relay substituting a forged identity key, matching WA Web’s SessionApi.createSignalSession.
Account key resolution mirrors WA Web’s validateADVwithIdentityKey (e.accountSignatureKey || t):
- In-blob key: If
ADVSignedDeviceIdentity.account_signature_keyis present and non-empty, it is used directly. - Stored identity fallback: The server legitimately omits this field for a contact’s companion because the client already holds the contact’s primary (device 0) identity in the Signal identity store. When the field is absent,
Client::load_account_identityloads it — reading through theSignalStoreCacheso any unflushed mutations from the current session are visible.PreKeyFetchSpec::with_account_identitiesthreads the pre-loaded map intowacore’s stateless prekey parser, keeping store access in thewhatsapp-rustcrate.
wacore::adv::AdvValidation):
NoAccountKey does not weaken security beyond the pre-existing “device-identity absent” path: a relay could already strip the entire <device-identity> element to bypass the check. It exists so brand-new contacts whose primary identity has never been seen are not silently dropped.
The same three-state validation applies in the retry-receipt handler (src/retry.rs) when a companion device requests a re-send.
Location: wacore/src/adv.rs, wacore/src/iq/prekeys.rs, src/prekeys.rs
Storage Integration
whatsapp-rust integrates Signal Protocol storage through a layered architecture:Device struct implements the libsignal SessionStore, IdentityKeyStore, and other traits. These are wrapped by SignalProtocolStoreAdapter, which adds the SignalStoreCache layer — sessions are cached as SessionRecord objects (not bytes), with serialization deferred to flush().
Each store (sessions, identities, sender keys) is flushed independently under its own lock. Only one store is locked during its I/O — the other two remain free for concurrent encrypt/decrypt operations. The lock is held from snapshot through write through clear, so mutations to the same store are blocked until flush completes, preventing dirty-set races:
Flush scheduling: send vs. receive
When the dirty Signal cache reaches the backend differs by direction, because the two directions have different recovery properties:- Send (DM/1:1 sessions) persists through a batched counter lease.
SessionRecordreserves its outbound sender-chain counterSENDER_CHAIN_RESERVATION_BATCH(64) values at a time, viaSessionRecord::reserve_sender_chain_counters. A send covered by an unexhausted lease is already durable — it only schedules the same coalesced write-behind as the receive path below. The send that exhausts the lease, roughly 1 in 64, raises the ceiling and flushes synchronously, before the stanza reaches the wire. If that flush fails, the send aborts instead of transmitting an advance it couldn’t save. Reusing an outbound counter reuses its message key and IV, so no counter can ever be used before its lease is durable. The lease field is local-only: it’s field 100 in the encodedSessionRecord, outside the vendoredwhatsapp.proto. By default (SessionRecord::deserialize), every load fast-forwards the sender chain to the lease ceiling, so a crash mid-lease can never re-derive a possibly-spent counter — the store-backed load path relaxes this only for a trusted reload, via the incarnation marker described below. - Send (group and status sends) follows the same lease pattern as DMs.
SenderKeyRecordreserves its outbound chain iterationSENDER_CHAIN_RESERVATION_BATCH(64) at a time viaSenderKeyRecord::reserve_iterations, using the same field-100 local-only encoding and the same fast-forward-on-load recovery asSessionRecord. A send within an unexhausted lease rides the coalesced write-behind; only the send that raises the ceiling, roughly 1 in 64, flushes synchronously before the stanza reaches the wire. Status posts go through the same group-encrypt path and inherit this behavior; status reactions are a DM-branch send and always followed the DM lease instead. Production encryption (wacore::send::encrypt_group_message) delegates to the samegroup_encryptprimitive these guarantees live in, so there is exactly one sender-key encrypt/advance/store implementation — earlier, a second unguarded copy on the production path meant most warm group sends skipped the pre-wire flush entirely. - Receive (live traffic, outside the offline-drain batcher) routes through a single-flight coalescing scheduler (
src/signal_flush.rs) instead of flushing per stanza: a burst of receives folds into one flush per ~25ms window, retried with exponential backoff (up to a 5s cap) on backend failure. This is safe because a lost receive-side advance simply re-derives forward on the next message (the receiving chain derivesCK_n → CK_n+1), and a consumed one-time prekey stays buffered until its session is durable — a crash inside the window is recoverable.
Signal::encrypt_group_message and Signal::create_participant_nodes accessors, plus VoIP’s outbound call-key fanout (place_call in src/voip/facade.rs), used to call Client::flush_signal_cache_batch_safe() — an unconditional flush — after releasing their session or sender-key chain locks, regardless of whether the mutation actually raised a lease’s durable ceiling. They now call the same Client::persist_signal_state_pre_wire() gate the primary wacore::send path uses: a warm call already covered by an existing lease rides the coalesced write-behind and returns immediately, while only the roughly-1-in-64 call that raises the ceiling still flushes synchronously before its ciphertext is used or sent. Measured against the same real-SQLite harness: warm group encryption dropped from 84.69 ms to 23.98 ms per 1,024-call sample (-71.7%), and warm 4-recipient participant fanout dropped from 49.16 ms to 8.69 ms per 256-call sample (-82.3%). Safety is unchanged — the per-entry durability check that guards what gets written is identical in both cases; the difference is that flush_signal_cache_batch_safe() always entered the flush path even on a warm lease, while persist_signal_state_pre_wire() skips it entirely once the raised lease is already durable. This removes redundant synchronous I/O, not any safety guarantee.
Deleting a session or sender-key record — an identity change, a session reset, a rotated sender key — creates a tombstone rather than clearing the record’s pending gate immediately. If a durability gate was open on the record at delete time, SignalStoreCache keeps it open until the backend’s delete_session/delete_sender_key call actually succeeds; a failed delete leaves needs_pre_wire_flush() returning true and is retried on the next flush. Earlier, the gate was released as soon as the tombstone was applied to the in-memory cache, so a failed delete or a crash in that window could let ciphertext reach the wire while the pre-delete chain state was still loadable from the backend, re-deriving already-used key material on reload. A lossy clear() still drops a pending tombstone gate — the tombstone is discarded from the in-memory cache without issuing the backend delete, so the old chain state may remain in the backend.
Clean reload vs. crash recovery
Fast-forwarding past a lease’s reserved ceiling on every reload is the safe default, but it’s also overly conservative for the common case: a clean reconnect or a same-process store re-creation never actually risked losing an in-flight send, yet unconditionally fast-forwarding still burned a full unused batch every time. 32 clean reconnects could push a sender-key chain 2,048 iterations ahead and get rejected once a peer who missed the intervening messages hitMAX_FORWARD_JUMPS (2,000).
SignalStoreCache, and the direct, non-cached Device store, now tag a durably-reserved record with a random 128-bit store incarnation, carried in a second local-only field (101, alongside the lease’s field 100) on both SessionRecord and SenderKeyRecord:
- A live
SignalStoreCachegenerates one incarnation marker when it’s constructed. A reload that observes a matching marker proves the record came from this same live cache and was never lost to a crash, so it’s exact: the chain resumes at the next iteration instead of fast-forwarding. - A reload with no marker, a different marker, or a malformed/duplicated marker is untrusted and keeps the conservative fast-forward — this covers process restarts, a freshly constructed cache, and any genuinely lossy discard.
SignalStoreCache::clear_after_flush(), used by connection teardown, only evicts a store once it’s fully settled — no dirty, deleted, checked-out, or pending-wire-gate entries. Anything a concurrent write installs after the flush stays resident (and the incarnation stays put) until a later flush settles it; only an actual lossy discard rotates the marker.- Direct
Devicestores hold one process-level incarnation in anOnceLockinstead of a per-cache one. Since these stores synchronously await the backend write before returning ciphertext, a newDevicewrapping the same backend in the same process is a clean, trusted reload; a process restart gets a fresh marker and stays conservative.
Device struct and no synchronous I/O — a marker is generated only when a cache or process starts, or when a lossy boundary invalidates trust.
The scheduler is generation-scoped (embeds the connection generation in its atomic state), so a reconnect during an in-flight flush needs no explicit reset: a stale worker from the previous connection cannot mutate the new generation’s state, and stands down when it observes a foreign generation.
The offline drain, identity-change recovery, and teardown all keep their own synchronous flushes — they gate acks, receipts, or follow-up reads on durability and are not routed through the receive coalescer. (Teardown’s flush is itself conditional: teardown_inbound_commits_bounded only flushes on a durable drain with no outstanding batch entries; on a timeout or non-durable/pending entries, it clears the cache without flushing instead, relying on server redelivery rather than risking a persisted-but-incomplete advance.) See Inbound Durability Hook for the drain-batch commit ordering, which this coalescing does not change.
Retry-receipt recovery (handle_retry_receipt resending to a DM or group requester, src/retry.rs) instead shares the DM/group send durability rule above through one helper, send_retry_stanza: the session lock is released first, then persist_signal_state_pre_wire() runs — flushing synchronously if the retry’s Signal advance crossed an unpersisted lease boundary — before the stanza is written to the wire. An Err from that flush aborts the retry instead of transmitting an advance it couldn’t save; for a DM retry specifically, see the point-in-time caveat above for the narrower case where the flush reports success without having actually persisted this retry’s own checked-out session (group retries are not exposed to that race — see the same caveat). This replaced an earlier unconditional full flush that ran only after the retry stanza had already reached the wire — a crash or persistence failure in that window could reload the old chain state after the ciphertext was already sent.
Call Client::flush_pending_signal_state() to force a deterministic settle — e.g. before reading persisted Signal state directly, or ahead of a non-graceful shutdown. Never call it from inside an InboundDurabilityHook or a synchronous, inline EventHandler::handle_event implementation, since settling re-enters the processing permit those run under and would deadlock during an offline-sync drain. Ordinary Bot closure handlers are unaffected — both default delivery modes run the callback in a detached task off the permit.
DH ratchet resets rebase the lease
A DH ratchet doesn’t extend the current sender chain — it replaces it in place. The ratchet installs fresh key material from a new random ephemeral at counter zero and drops the retired chain instead of archiving it. The counter lease described above is a record-level ceiling, but the chain it bounds is per-ratchet-epoch. Without a matching lease rebase, the ceiling keeps describing a chain that no longer exists. For ping-pong traffic, that gap is one batch and you’d never notice it. It’s different for a peer you only ever monologue at — say, your own other device, which gets a copy of every message you send but rarely replies. The chain climbs pastMAX_RESERVATION_FAST_FORWARD before one reply triggers the ratchet, and the ceiling ends up stranded thousands of counters above a chain that just restarted at zero.
A live reload never surfaces this, since a trusted-incarnation reload (above) skips the fast-forward entirely. The gap only shows up on recovery — a restart, or any lossy cache reset. There, the reload has to fast-forward across a span no send ever created. It refuses past MAX_RESERVATION_FAST_FORWARD and fails the whole record load. From that point the address is stranded: every path that could repair the session — inbound decrypt, the group-send fan-out, the retry-receipt handler — has to load the unloadable record first.
SessionRecord::rebase_lease_after_sender_chain_reset() closes this gap. As part of the same mutation that swaps in the fresh chain, it lowers the ceiling to at most one SENDER_CHAIN_RESERVATION_BATCH. It only ever lowers, never raises, so you can never publish a counter under a ceiling that isn’t yet durable. The lowering happens atomically with the chain swap, so no snapshot can pair the retired chain with a ceiling rebased for it, or vice versa. Rebasing to one batch instead of zero keeps the fresh chain’s first counters lease-covered, so steady-state ping-pong keeps its write-behind send path instead of paying a synchronous flush on the very next send. A chain that’s archived rather than discarded keeps its claim on the lease instead: promote_fresh_state burns the outgoing state to the ceiling before resetting it.
If a record already has a stranded ceiling — written by a build that predates this fix — it recovers on its own the next time you use that address; you don’t need to delete the row by hand. See undecodable session rows.
Waiving the counter lease
The batched lease above assumes the durable snapshotSessionRecord/SenderKeyRecord serialize to is the store of record. Some consumers don’t fit that assumption: their own persistence is already synchronous and durable before the ciphertext reaches the wire, so the lease gives them nothing. The component export case below is the motivating one. Such a consumer still pays for the lease, because into_components() has to materialize the full reservation on every export — nothing else in the projection could re-derive it later. Four consecutive DM sends through such a store land on the wire at counters 0, 64, 128, 192 instead of 0, 1, 2, 3, and the peer buffers 63 skipped message keys per gap.
SessionRecord::waive_counter_lease() and SenderKeyRecord::waive_counter_lease() let that consumer say so, per record, once it’s loaded:
- The policy is the consumer’s, never inferred. Nothing about a record’s stored representation says whether a lease is in force — the same components can come from a consumer that wants the lease and one that does not — so this call has to happen on every load that should waive it, not just once. There is no build feature or store-shape heuristic that does it implicitly.
- This gives up a real guarantee. Message keys and IVs derive deterministically from the counter, so without the lease a crash between the encrypt and the write can reissue a counter and, with it, its (key, IV) pair. Only make this trade if persistence is synchronous and durable before the wire — the same property a direct
Devicestore already has for itself, since it awaits the backend write before returning ciphertext (see store incarnation).SignalStoreCache’s trusted reload relies on a different, weaker guarantee — a matching live-cache incarnation, not synchronous durability, since its warm sends ride the coalesced write-behind — so don’t read the two as the same requirement. - A reservation the record already carries still burns once. A record loaded from a snapshot written while the lease was in force may already have published counters below its ceiling; waiving doesn’t make that untrue. The call materializes that ceiling into the chain — archived states included, the same way
into_components()already would — and then the lease is gone, so it only pays that cost once instead of on every subsequent export. - An unadvanceable ceiling is handled differently per record, matching how their exports already differ:
SessionRecord::waive_counter_lease()has no failure path — a chain too stale to fast-forward is dropped fail-closed, per session, without discarding the rest of the record.SenderKeyRecordhas one shared chain rather than per-peer sessions, so dropping it isn’t a safe partial failure;waive_counter_lease()returnsErrinstead and leaves the record on its lease rather than dropping the ceiling and risking reissue. - The default is unchanged. A record that never calls this keeps the lease, the wire gate, and the fast-forward-on-load exactly as documented above.
wacore/libsignal/src/protocol/counter_lease.rs, wacore/libsignal/src/protocol/state/session.rs, wacore/libsignal/src/protocol/sender_keys.rs
Record components
wacore-libsignal exposes owned, validated projections of SessionRecord and SenderKeyRecord called components. Use them when you need to interchange or inspect session and sender-key record state without depending on the generated protobuf schema directly — for example in custom store implementations, migration tooling, or offline debugging. This API is purely additive: the protobuf-backed serialize()/deserialize() path is unchanged. A record does not round-trip through into_components() → from_components() → serialize() byte-for-byte — the conversion applies the validated, normalized export rules described below (counter-lease advancement, stale-chain removal, and bounded truncation), so treat it as a safe normalized re-encoding rather than a lossless copy. A store built on components alone re-materializes the lease’s reservation on every export; see Waiving the counter lease above if that store’s own persistence is already durable before the wire.
Session and sender-key shapes
SessionRecordComponents mirrors the current_session / archived previous_sessions split already described in Arc previous sessions; SenderKeyRecordComponents mirrors a SenderKeyRecord’s state list:
SessionMessageKeyComponents and SenderMessageKeyComponents hold skipped out-of-order message keys, keyed by chain index/iteration. A session message key’s secret material is SessionMessageKeyMaterial:
Seed was only a compact import form, expanded through the same canonical derivation used elsewhere (see MessageKeyGenerator). Now a skipped key’s seed round-trips back out on export too. session_structure::chain::MessageKey carries an additive seed field (local field 100) alongside the pre-existing cipher_key/mac_key/iv. from_structure prefers the seed when present: it re-derives from the seed and rejects the record if the result disagrees with the stored triple, since the seed supersedes the triple on export and a mismatch means a corrupt record. Derived now comes back only for a key persisted before this change — one that never wrote a seed. Both variants are fixed-width and Copy, so into_structure(), the reverse direction, is now infallible; it used to validate a Vec length.
Breaking for direct consumers of these types.
SessionMessageKeyMaterial::Seed and Derived switched from Vec<u8> fields to fixed-width arrays. Match both variants — don’t assume Derived is the only exported form. MessageKeyGenerator::Keys(MessageKeys) was also removed; nothing in the workspace constructed it. Build a seeded key with MessageKeyGenerator::new_from_seed instead. If you downgrade to a build predating #1210 and write a record back out, the seed drops silently — decrypt is unaffected, but the key becomes unexportable again.Import validation
from_components enforces the same structural invariants the canonical protobuf reader relies on elsewhere in this codebase, rather than accepting whatever shape the caller hands it:
- A sender chain (the local sending ratchet chain within a pairwise session — not to be confused with a group sender-key chain) must be structurally complete: ratchet public key present, a 32-byte ratchet private key, and a chain key with both its index and 32-byte secret set. An incomplete sender chain fails with
SignalProtocolError::InvalidArgument; a session with no sender chain at all (e.g. one just received and not yet replied to) is fine and imports assender_chain: None. - A receiver chain must never carry
sender_ratchet_key_private— receiver chains never own the remote party’s private key, so import fails if one is set. Symmetrically, projecting a persisted record into components always reports a receiver chain’ssender_ratchet_key_privateasNone, silently dropping any non-canonical private material a legacy record might contain, matching how the canonical reader already treats that field. - Raw 32-byte public keys and canonically-serialized public keys (type byte + 32 bytes) are both accepted on import; whichever form was imported,
into_components()always exports canonical serialization.
Export normalization
into_components() never hands back a session or sender-key chain whose counter could be replayed on re-import:
- Any durably reserved sender-chain counter range — see the counter-lease mechanics in Flush scheduling: send vs. receive — is advanced to its exclusive ceiling before export. Re-importing the exported components can’t reuse a counter value that was only reserved, not yet actually sent.
- A sender chain too stale to fast-forward past its reservation is dropped from the exported chain rather than aborting the whole export — the rest of the record (receiver chains, other archived sessions) still exports normally.
SessionRecordComponents.previous_sessionsis truncated toARCHIVED_STATES_MAX_LENGTH(40) andSenderKeyRecordComponents.statesis truncated toMAX_SENDER_KEY_STATES(5) — the same bounds the records themselves already enforce. See Protocol safety limits.
has_usable_sender_chain
Ok(false) (not an error) when no sender chain is set at all, and otherwise structurally validates the ratchet public key, the ratchet private key, and the chain key are all present before returning Ok(true) — the same completeness check from_components applies on import. SessionRecord::has_usable_sender_chain delegates to the current session’s check, returning Ok(false) when there is no current session.
Location: wacore/libsignal/src/protocol/state/session.rs
Debug output redacts secrets
Debug on every *Components type — SessionComponents, SessionChainComponents, SessionChainKeyComponents, PendingKeyExchangeComponents, PendingPreKeyComponents, SenderChainKeyComponents, SenderSigningKeyComponents, SenderMessageKeyComponents, and SessionMessageKeyMaterial — prints private keys, chain/root keys, seeds, and cipher/mac/IV material as <redacted>, while structural fields (indices, counters, iteration numbers, key presence) print plainly:
*Components value in application code without writing a custom Debug impl to avoid leaking key material.
Example — inspecting whether a session can currently send, without touching protobuf types:
wacore/libsignal/src/protocol/record_components.rs
Legacy session v1 interop
Behind the opt-inlegacy-session-interop Cargo feature (default off, forwarded through wacore and the root whatsapp-rust crate), wacore-libsignal exposes a typed, transport-agnostic model of the decoded legacy libsignal SessionRecord v1 layout — the format this project’s stores used before the canonical shapes above. It exists for migration tooling importing an externally produced v1 store into the canonical SessionRecord, or projecting a canonical record back into v1 terms; ordinary clients never enable it, so the model compiles out of native builds entirely.
Container decoding — turning a legacy store’s transport bytes into these typed fields — is explicitly out of scope; callers own that step and hand this module owned values (Bytes, integers, enums). The module owns everything downstream: chain-role selection, counter translation, lifecycle ordering, pruning, ratchet reconstruction, and skipped-key derivation.
from_indexed_sessions validates that each entry’s outer map key matches its own session’s base key and rejects duplicate base keys or more than one Current session. into_session_record then validates every session — chain roles, key lengths, counters, skipped-key indexes, pending pre-keys, and the same canonical limits enforced in Import validation — retains archived sessions by close time, reorders them by last use to match the v1 decrypt search, truncates to ARCHIVED_STATES_MAX_LENGTH, and reuses SessionRecord::from_components to build the canonical record. LegacySessionLocalContext supplies the local identity key and registration ID, since v1 sessions never persisted them.
A v1 sending chain seeds previous_counter at -1 for a ratchet step over a chain that has never sent; LegacySessionChainCounterV1 floors that at zero on import instead of treating it as an error. Every other out-of-range counter fails typed as InvalidChainCounter.
Export — canonical into v1 (operational, not byte-exact):
session_version, a pending key exchange, or a needs_refresh flag all fail as NotRepresentable; a pending pre-key whose base key doesn’t match the session’s own base key fails as PendingPreKeyBaseMismatch rather than producing v1 state the importer would reject on the way back in.
A receiver chain holding a derived (seedless) skipped-message key fails as ChainNotRepresentable::DerivedMessageKey — it has no inverse to a v1 seed. Before #1210, this was every skipped key without exception. Import expanded a v1 seed into derived keys, and export could never recover it, so a v1 record with a skipped key failed to round-trip even on the very first cycle. Now that the seed rides along with the keys it derives (see SessionMessageKeyMaterial above), only a key persisted before that change still hits this error — one that never had a seed to retain. A skipped key imported or received after #1210 carries its seed through export and projects normally. That holds permanently, until the key is consumed or evicted.
CI runs a dedicated
cargo nextest run --features legacy-session-interop job as of #1210. The feature was previously off in every job. This entire module’s test suite — including the regression test for the bug above — compiled away and never ran.Debug — root keys, chain keys, ratchet key pairs, skipped-message seeds, and identity keys all print as <redacted>; only structural fields (roles, counters, indexes, session/chain counts) print plainly, the same convention as the canonical *Components types.
Location: wacore/libsignal/src/protocol/legacy_session.rs
Security Considerations
Identity key trust
The implementation verifies identity keys before encryption/decryption:is_trusted_identity resolver (see Unboxed identity and session hooks) rather than the trait method directly, so a store’s try_is_trusted_identity hook gets a chance to answer first.
Location: wacore/libsignal/src/protocol/session_cipher.rs:309-325
Self-only protocol message gating
app_state_sync_key_share, app_state_sync_key_request, and history_sync_notification are protocol messages WhatsApp Web treats as “self-only”: they only carry meaning when delivered from your own account to another of your linked devices. v0.6 hardens handle_decrypted_plaintext so that incoming copies of these messages are dropped unless MessageInfo.source.is_from_me is true, matching WA Web’s WAWebKeyManagementHandleKeyShareApi and whatsmeow’s gating.
The consequences if the gate is missing:
- A spoofed
app_state_sync_key_sharefrom a peer would let an attacker inject an app-state encryption key, leading to attacker-controlled mutations of your contacts, blocklist, archive state, etc. - A spoofed
app_state_sync_key_requestfrom a peer would make the client share its app-state encryption keys with an attacker instead of only with the account’s own companion devices. - A spoofed
history_sync_notificationwould point the client at attacker-supplied media for ingestion as your own history.
is_from_me check before honoring any of these protocol messages. Other protocol-message types (REVOKE, EPHEMERAL_SETTING, MESSAGE_EDIT, …) keep their existing semantics.
Location: src/message.rs (handle_decrypted_plaintext)
Duplicate message detection
The protocol detects and rejects duplicate messages:wacore/libsignal/src/protocol/session_cipher.rs:822-827
As of #1072, a DuplicatedMessage from one candidate session is not terminal — the search keeps trying the remaining current and archived sessions, including a closed receiver chain (one with no chain-key seed left, only leftover skipped keys). This covers re-initiations, which reuse the peer’s signed pre-key as a ratchet key: a delayed message whose skipped key survives only in an archived session still decrypts, and a closed chain still recognizes a replay of an already-consumed counter instead of falling through to a generic failure.
Once every session has been tried, a recognized DuplicatedMessage outranks BadMac in the final classification: a sibling session that happens to share the same ratchet key derives different message keys for the same counter and fails its MAC as expected noise, but the decrypting client already knows this counter was consumed elsewhere. Classifying that as BadMac would trigger a retry receipt for a message the peer already delivered; the duplicate verdict wins instead, so the replay is acknowledged and silently dropped.
Log level discipline
The protocol layer follows strict rules about what cryptographic material appears in logs and at which level:- No private keys or secrets are ever logged —
ChainKey,MessageKeys, andRootKeytypes do not expose their key bytes through logging - Public keys appear only at
warn/errorlevels — and only when something has gone wrong (untrusted identity, MAC failure) - MAC key fingerprints are truncated — only the first 4 bytes (8 hex chars) are logged during MAC verification failures, not the full key:
- Ratchet keys in debug logs — successful decryptions log the sender ratchet public key (never private) at
debuglevel for diagnostics - Pre-key operations use
debugfor routine operations andwarn/infofor exceptional conditions
The Signal protocol layer (
wacore/libsignal/src/protocol/) uses no trace!-level logging. Sensitive operations stay at debug or above to avoid leaking material in verbose log configurations.Session state corruption
Detailed logging helps diagnose crypto failures:- All attempted session states
- Receiver chain information
- Message metadata (sender ratchet key, counter)
wacore/libsignal/src/protocol/session_cipher.rs:365-454
Protocol safety limits
The implementation enforces several hard limits to prevent resource exhaustion and cryptographic failures:
Location:
wacore/libsignal/src/protocol/consts.rs
Self-DM / sibling decryption recovery
When a message from your own primary phone or another linked companion fails to decrypt, the v0.6 client distinguishes the failure mode and applies the matching recovery strategy:
The throttle is a per-peer cooldown (1-hour TTL after the last recreate). In v0.6 the implementation moved from a
Mutex<HashMap<Jid, Instant>> to a bounded TTL cache (PortableCache, ~256 entries): the per-peer check-and-stamp is now atomic (serialized by the existing per-peer session lock) and lock-free at the map level, so concurrent retry-receipt spawns from the same peer can’t trigger duplicate recreates. The behavior is unchanged — if a peer is already in cooldown, the client skips re-creation and falls back to a normal retry receipt rather than thrashing the session. Under more than ~256 distinct peers retrying within the window, the cache may evict a recent entry, costing at most one extra recreate (bounded and self-healing). Peer-addressed pkmsg carries the protocol identity so the receiver can verify ownership before installing the new session, blocking spoofed sibling recoveries.
This closed a deadlock where self-DM fan-out to a sibling device produced repeated BadMac decrypt failures: the recipient would request a retry, the sender would re-encrypt against the same broken session, and the cycle would continue until the user manually relogged. With the throttled re-creation plus identity-validated pkmsg, the second receipt installs a fresh session and decryption resumes.
Self-DM fan-out also gained WA Web parity for the BadMac case: when our own primary phone reports BadMac, the client now treats it as a session-level recovery rather than dropping the message, matching WAWebDecryptOrThrow’s branch on session divergence.
Location: src/client.rs, src/retry.rs, wacore/libsignal/src/protocol/session_cipher.rs, wacore/src/send.rs
Performance optimizations
Session object cache
TheSignalStoreCache stores sessions and sender keys as deserialized objects (SessionRecord and SenderKeyRecord) rather than serialized bytes, matching WhatsApp Web’s architecture where the JS object IS the cache. Serialization only happens during flush() to the database — not on every store_session or put_sender_key call.
store_session method takes SessionRecord by value, enabling zero-cost moves from the protocol layer:
message_encrypt, message_decrypt_signal, message_decrypt_prekey, process_prekey_bundle) — plus PreKey setup and group preflight mutations — take ownership of the record and drop it immediately after storing. Taking ownership eliminates the .clone() in the adapter and the compiler enforces no use-after-store. As of #1044, these call sites obtain the record through the SessionCheckout guard (below) rather than a bare load_session/store_session pair, so the zero-cost move still applies but the checkout is now cancellation-safe.
Per-message hot path impact:
Cancellation-safe session checkouts
As of #1044, the record backing aCheckedOut entry is owned by a SessionCheckout<'a> guard (wacore/libsignal/src/protocol/storage/traits.rs) instead of being a bare marker. This closes a gap where cancelling the future mid-mutation — a Tokio task abort, a select! losing a race, a timeout — could leave only the marker behind: the ratchet advance the future was computing was lost, and a decrypt’s ratchet advance could persist without its matching identity write, or vice versa.
SessionCheckout is obtained via SessionCheckout::load (existing session) or load_or_create (synthesizes SessionRecord::new_fresh() if absent, tracked via had_session), exposes record()/record_mut() for the protocol layer to mutate in place, and is consumed by either:
commit(self)— stores the mutated record back throughtry_store_session_from_checkout, ordiscard(self)— releases a deliberately-rejected fresh (never-had-a-session) checkout without storing.
Drop synchronously puts the record back itself — no caller code has to remember to handle cancellation. If the sessions mutex is uncontended, restoration is immediate; if a concurrent flush() holds the lock, the restore is queued as a PendingSessionRestore and replayed the next time any operation acquires the sessions lock, so an aborted task’s state is never dropped even though it can’t wait for the lock itself. The queue itself (SyncMutex<Vec<PendingSessionRestore>>) is unbounded — every lock acquisition drains it first, so it only grows if cancellations under lock contention arrive faster than anything else touches the session store, which does not happen under normal load.
Every checkout carries a SessionCheckoutKey { generation, token }: checkout_generation increments on every lossy clear(), and token is assigned monotonically per checkout of a given address. A restore is rejected (rather than silently applied) unless both match the cache’s current state — this stops a checkout issued before a session reset from resurrecting stale state, and stops a stale owner from clobbering a newer checkout of the same address.
flush() and cache eviction (clear_after_flush) now preserve live CheckedOut entries — and any PreKey deletion buffered against that address — instead of dropping them; the deferred prekey delete and the session’s own persistence both wait for a later flush once the checkout completes, rather than being lost.
SessionStore gained five #[doc(hidden)] methods with pass-through default bodies (load_session_for_update, try_load_session_for_update, try_store_session_from_checkout, cancel_session_checkout, complete_session_checkout), so existing custom SessionStore implementations keep compiling unchanged — only a backend that wants the destructive-update fast path needs to implement them.
Unboxed identity and session hooks
As of #1124, the same “sync hook first, boxed async fallback second” pattern above extends to three more per-message checks:IdentityKeyStore::try_is_trusted_identity, IdentityKeyStore::try_save_identity, and SessionStore::try_has_session. #[async_trait] boxes every store method into a Pin<Box<dyn Future>>, regardless of whether the answer is already known. The bundled IdentityAdapter’s async is_trusted_identity body is an unconditional Ok(true) — WA Web isTrustedIdentity parity, since identity changes surface through save_identity rather than through trust checks. That box was therefore pure overhead, paid once per encrypt and once per decrypt.
Each hook defaults to None, and None means “I cannot answer synchronously” — never “the answer is the default”:
try_save_identitydeclines when nothing is cached for the address, so the caller reads the backend. Answering from an empty cache would report every identity as new.try_has_sessiondeclines when the cache cannot answer, rather than reporting “no session” and forcing a needless session rebuild.try_is_trusted_identitycan always answer for the bundled adapter specifically, because its asyncis_trusted_identityis already the unconditionalOk(true)above. This is not a general license to short-circuit trust checks: a customIdentityKeyStorewhose asyncis_trusted_identityenforces a real policy must not copy this hook verbatim — returningSome(Ok(true))unconditionally would let it bypass that policy. Such a store should returnNoneunless it can decide synchronously without skipping any of its own trust logic.
wacore::libsignal::protocol — is_trusted_identity, save_identity, has_session — express “ask the hook, then await the fallback” once instead of at each of the eight call sites that previously called the trait methods directly:
message_encrypt, message_decrypt_signal, and ensure_sessions_for_devices now call through these resolvers instead of the trait methods directly. The hooks are purely additive: a custom IdentityKeyStore/SessionStore that implements only the async methods keeps compiling and behaving unchanged, since declining both hooks falls through to exactly the async path it always ran.
Arc previous sessions
SessionRecord.previous_sessions is wrapped in Arc<Vec<SessionStructure>>, making clone O(1) for the ~40 archived previous sessions that previously accounted for ~40% of the serialize cost:
Arc::make_mut and a deep copy.
Chain key buffer reuse
As of #1137, advancing a chain key no longer allocates a fresh buffer for its persisted 32-byte key material on every step.SessionState stores each chain key’s bytes as Option<bytes::Bytes> on the underlying protobuf ChainKey field. Bytes is immutable, so writing the ratcheted key used to be an unconditional Bytes::copy_from_slice(..) on every send or receive that advances a session’s chain key. In the harness benchmark that motivated this change — a single-device 1:1 pingpong session — a full message round trip advances chain keys three times (twice sending, once receiving); a real send can touch more sessions than that, since DM device fanout encrypts separately for every resolved recipient and own-device session.
write_chain_key (wacore/libsignal/src/protocol/state/session.rs) instead reuses the existing buffer in place when it safely can:
try_into_mut() succeeds solely when the Bytes is uniquely owned (no other clone observing the old key), and the length check keeps a differently-sized buffer (e.g. from a legacy record) from reaching copy_from_slice at all — like the slice method it resolves to via DerefMut, a length mismatch there panics rather than writing anything. Either guard failing falls back to the original allocating behavior. In steady state a checked-out session record is uniquely owned — the cache takes it out of its Arc via try_unwrap (see Session object cache above) — so the fallback is rare.
Redundant signal store write elimination
TheSignalStoreCache uses targeted deduplication strategies per store type. For identities (which rarely change), put_dedup() compares incoming bytes against the cached value and skips if identical:
put() since they change with every message — dedup would always fail and waste CPU cycles. This split avoids unnecessary database writes during flush() while not adding overhead where it provides no benefit.
Key reuse in cache
Thekey_for() method on SessionStoreState, SenderKeyStoreState, and ByteStoreState reuses existing Arc<str> keys from the HashMap via get_key_value(), avoiding a heap allocation on every cache operation:
Single-allocation session lock keys
Session lock keys use the full Signal protocol address string (e.g.,5511999887766@c.us.0). The JidExt trait provides methods for generating these strings, defined in wacore/src/types/jid.rs:
to_protocol_address_string() is used on hot paths (message encryption and decryption) as the key for session_locks. It pre-sizes the output buffer and builds the String in a single allocation. Constructing a ProtocolAddress itself no longer allocates for addresses that fit inline (see Single-buffer ProtocolAddress below), but .to_string() on top of it still does, so to_protocol_address_string() remains the cheaper path when only the string is needed.
The write_protocol_address_to() free function provides the same formatting but writes into a caller-supplied &mut String buffer, enabling buffer reuse across multiple JIDs.
Format examples:
The server
s.whatsapp.net is mapped to c.us in address strings, matching WhatsApp Web’s internal format. The trailing .0 is the Signal device_id (always 0 in WhatsApp’s usage).WAWebSendUserMsgJob behavior where the local device table is read on the send path, and WAWebDBDeviceListFanout filters out hosted devices. The client checks the local device registry first (via get_devices_from_registry()); a network fetch is only triggered on a cache miss to avoid unnecessary LID-migration side effects from get_user_devices. The sender device is excluded (matching WA Web’s isMeDevice in getFanOutList), and for self-DMs, overlapping device lists are deduplicated using a HashSet (matching WA Web’s Map keyed by toString).
Own-device namespace alignment (v0.6): When the recipient is addressed in the LID namespace (@lid), the client converts its own companion devices from the PN namespace to LID before fanning out. Without this alignment, a <to> mix of @lid and @s.whatsapp.net participants caused the server to reject the stanza for LID-addressed DMs. Outgoing messages to PN-addressed recipients are unaffected.
The recipient’s namespace here is decided by
resolve_dm_wire_jid(), not a raw mapping lookup — on an account that isn’t 1:1-LID-migrated, the recipient (and therefore the whole fanout) stays PN even when a LID mapping is cached.DeviceSentMessage.
WA Web has a bare-
<enc> fast path for single primary device (WAWebSendMsgCreateFanoutStanza). This is not implemented in whatsapp-rust because encrypt_for_devices always wraps in <to jid=...> nodes. The <participants> form is accepted by the server regardless.Fail-fast on total encrypt failure (v0.6). If per-device encryption fails for every recipient device, the DM send now returns an error instead of emitting a stanza with an empty participant list (which the server would silently swallow, making the message look sent when it wasn’t). A partial failure — some devices encrypt, some don’t — still sends to the devices that succeeded.Recipient and own-device results are checked separately (PR #1299). The recipient half and the own-companion half of the fan-out write into the same
<participants> list. The emptiness check above only ever caught the case where neither half produced a node. It missed a stanza built from own companions alone: every recipient device failed, one own companion still encrypted, and the list was non-empty. The server acked that stanza, and the recipient never received it. The recipient half is now checked immediately after it runs, before the own-device half is attempted at all. An empty recipient half (for a non-self destination) or an all-failed recipient half returns SendError::NoRecipientDevice — see NoRecipientDeviceError. No own-companion sender chain advances for a stanza that isn’t going out. The residual empty-participants guard above is now reachable only for a self chat where every own device fails.Client::build_session_lock_keys() resolves encryption JIDs and sorts them for deadlock-free lock acquisition:
- Resolves the recipient to its bare encryption JID via
resolve_encryption_jid().to_non_ad()(stripping device component) - Resolves own companion device JIDs
- Sorts by
(server, user, device)usingcmp_for_lock_order()and deduplicates - Returns sorted
Vec<Jid>— no intermediateStringallocations needed for sorting
build_session_lock_keys() is a thin composition of two pieces the DM path also calls directly: Client::resolve_encryption_jids() (step 1+2 above, resolve_encryption_jid() over the device list, in order) and a private sort_session_lock_keys() (step 3). The group path still calls build_session_lock_keys() as one unit through SendContextResolver::lock_device_sessions(); the DM path calls the two pieces separately so it can memoize the result on the dm_devices_memo entry instead of resolving fresh on every send:
dm_devices_memo entry (see the memoized Signal addressing note above), so the next send re-resolves rather than serving a stale address.
Resolving every mutex first and then locking each in a second pass (the previous implementation) is still available as
session_mutexes_for(), but only under #[cfg(test)] — production code no longer builds the intermediate Vec of mutex handles.100000012345678@lid.0), matching the decrypt path’s lock format. This ensures send and receive paths serialize on the exact same lock key.
Location: wacore/src/types/jid.rs:4-51, src/send.rs:1481-1507
Single-buffer ProtocolAddress
TheProtocolAddress struct stores the full address string "{name}.{device_id}" in a single AddressBuf buffer, with a name_len marker to split name from suffix. Since #1131, AddressBuf is inline-first: addresses up to 47 bytes (INLINE_CAPACITY) live inside the value itself with no heap allocation at all — every real WhatsApp address fits, e.g. "5511987650001:5@c.us.0" is 22 bytes. A longer address spills to a String.
Which arm holds the bytes is not part of the value: Eq, Ord, and Hash all read the rendered string via as_str(), so an inline-built key finds a heap-spilled entry (and vice versa) in the session cache. That equivalence is what makes the optimization safe rather than a silent cache-miss generator.
name() and as_str() are zero-cost slices into the same buffer — no allocations on access, whether inline or spilled.
API change (PR #1131):
ProtocolAddress::new now takes name: &str instead of an owned String. with_capacity(capacity, device_id) was removed in favor of empty(device_id) — the buffer no longer needs a capacity hint, since it starts inline and only allocates on overflow. reset_with()’s closure now receives &mut AddressBuf instead of &mut String; AddressBuf implements push_str, push, and std::fmt::Write, which covers existing call sites.Reusable hot-loop address construction
When iterating over many devices (e.g., during group stanza preparation or session resolution), allocating a freshProtocolAddress per device is wasteful. The JidExt trait provides reset_protocol_address() to rewrite a pre-allocated address in place, and make_reusable_protocol_address() creates the initial buffer:
ProtocolAddress for a typical WhatsApp address (up to 47 bytes) is already allocation-free — inline storage covers it. Reuse still matters for addresses that spill to the heap: the first spill on a reused buffer still allocates its backing String, but every reset after that keeps the existing allocation instead of dropping and reallocating one per device — for a group with 100 participant devices past the inline limit sharing a reused buffer, that saves up to 99 heap allocations on the send path.
Use
to_protocol_address() for one-shot address construction (e.g., cache keys, single lookups). Use make_reusable_protocol_address() + reset_protocol_address() when iterating over multiple JIDs in a tight loop.wacore/libsignal/src/core/address.rs, wacore/src/types/jid.rs, wacore/src/send.rs
Zero-Allocation JID Deduplication
Group stanza preparation needs to deduplicate participant JIDs at two stages: before device resolution (by user identity) and after LID conversion (by device identity). Two utility functions inwacore/src/types/jid.rs handle this with in-place sorted dedup instead of HashSet allocations:
sort_dedup_by_device keys on Jid::identity_agent() rather than the raw agent field, so its notion of “same device” matches exactly what Jid’s PartialEq/Hash already treat as equal (see Binary Protocol for why the two fields differ). That has to hold in both directions: keying on the raw agent would let two JIDs that are actually one device — an inert agent byte on Pn/Lid/Hosted/HostedLid, same AD-JID, same Signal address — both survive the dedup and pick up two concurrent encryption jobs against one session; dropping agent from the key entirely would go too far the other way and silently collapse two genuinely distinct @bot/@interop devices, which do render it, losing a fan-out destination.
Both use sort_unstable_by followed by dedup_by, comparing JID fields directly without allocating intermediate strings or hash sets. This is more efficient than the HashSet<(String, String)> approach because:
- No per-JID
String::clone()for hash keys - No
HashSetallocation or hashing overhead - Stable dedup order (sorted) instead of hash-dependent iteration
wacore/src/send.rs):
wacore/src/types/jid.rs:33-51
Take/Restore Pattern
Avoids cloning session states during decryption attempts:wacore/libsignal/src/protocol/session_cipher.rs:495-564
Buffer Reuse
Thread-local buffers eliminate per-message allocations:wacore/libsignal/src/protocol/session_cipher.rs:20-54
Prewarming sender-key derivations
SenderKeyState memoizes two curve derivations behind OnceLocks: the signing key’s Edwards conversion (PrivateKey::precompute_signing_cache) and the verifier’s Edwards entries (PreparedVerifyingKey::precompute). Both are expensive relative to the rest of an encrypt/decrypt. Both are cheap to reuse, as long as something keeps the memo warm across operations. If you keep the loaded SenderKeyRecord between operations, you get that for free. If you discard it instead — for example, if you persist through into_components() and reconstruct the record on every load — you previously had no way to keep the warm derivation either. The memo lives on the record, and the OnceLocks only fill from inside.
As of #1213, SenderKeyState exposes two setters. Use them to hand back a derivation you already computed, instead of letting the state re-derive it:
&self — OnceLock::set doesn’t need &mut. Reach them through SenderKeyRecord::sender_key_state() right after a fresh load:
- Correspondence is checked, not assumed. If you pass a key or verifier that doesn’t belong to this state’s signing key, it’s rejected with
InvalidSenderKeySessionError, and the state is left free to derive for itself. Accepting foreign material without checking would sign or verify under the wrong key. The check is cheap on both sides:PreparedVerifyingKeyalready holds the Montgomery bytes, soPreparedVerifyingKey::is_for(&PublicKey)is a 32-byte comparison. A private key compares by its clamped serialized form. Neither redoes the derivation you’re trying to skip. - Already-warm is a no-op, not an error. If you prewarm a memo that’s already populated — by a prior prewarm, or by lazy use — it’s left alone. The value in place is the same derivation, so surfacing an error would force you to track state that changes nothing.
- Hold the key warm before caching it, or you lose the payoff. Every read of the signing-key memo hands out a clone, and
PrivateKey’s Edwards cache is a per-instanceOnceLock— a clone of a cold key re-derives on its own.prewarm_signing_keywarms whatever you give it before storing it, so a single cold call still leaves the memo warm. But if you repeatedly hand over cold clones, you pay the derivation every time. CallPrivateKey::precompute_signing_cache()once when the key first enters your own cache, then clone the warm copy into eachprewarm_signing_keycall. You don’t need that care on the verifying-key side:PreparedVerifyingKey’s entries sit behind a shared handle, so warming one instance warms every clone of it, including the copy in your cache. - You take on the cache’s lifetime. Injecting a derivation makes you responsible for how long it — and any private material in it — lives, which the state would otherwise own only for its own lifetime. If you call neither setter, you’re unaffected: the lazy derivation behaves exactly as before.
wacore/benches/sender_key_derivation_benchmark.rs against a record kept warm the whole time and one rebuilt from components with no prewarming. CI reads these benchmarks by instruction count rather than wall-clock time — the figures below are simulated microseconds modeled from that count, stable across runs, and comparable to each other but not to a real-time measurement:
Prewarming recovers roughly 90% of the gap between rebuilding cold and keeping the record warm for group encrypt, and 82% for group decrypt; what’s left is the correspondence check plus the caller-side cache lookup — real work a record that’s never discarded doesn’t pay.
Location:
wacore/libsignal/src/protocol/sender_keys.rs, wacore/libsignal/src/core/curve.rs
Stack-backed buffers for addon crypto, ADV validation, and prekeys
As of #1324, more short-lived allocation sources — addon crypto, ADV validation,PreKeyBundle, the prekey store bridges, and session serialization — move to stack-backed storage, without changing the wire format, the crypto, or any existing call site.
Addon crypto (reactions, poll votes, message edits, comments): derive_use_case_secret’s HKDF info string (stanzaId || parentSender || modificationSender || <usecase>) and build_aad’s AAD buffer move from Vec<u8> to SmallVec aliases:
InfoBuffer spills to the heap once stanza_id.len() + parent_sender.len() + modification_sender.len() + use_case.len() exceeds 128 bytes. AadBuffer spills once stanza_id.len() + 1 + modification_sender.len() exceeds 96 bytes. Both limits cover WhatsApp’s real addressing shapes with room to spare; neither is enforced by protocol validation, so an unusually long stanza id or JID still works — it just falls back to a heap allocation instead of staying inline.
ADV identity validation (above): validate_adv_with_identity_key decodes through ADVSignedDeviceIdentityView instead of an owned ADVSignedDeviceIdentity, and the two signed messages verified per prefix family are assembled once into a stack buffer and re-prefixed per candidate, instead of rebuilt with .concat() on every attempt:
prefix(2) || details || identity(32) || accountKey(32), so this buffer spills once details.len() + 66 exceeds 256 bytes — comfortably above the couple dozen bytes a real ADVDeviceIdentity encodes to.
PreKeyBundle: the signed pre-key signature is a fixed-length XEdDSA signature, so it is now stored inline as [u8; 64] instead of behind a Vec<u8>:
signed_pre_key_signature accepts anything that converts into [u8; 64]: a [u8; 64] fresh off calculate_signature passes through with no allocation, while a Vec<u8> from a store row is checked and rejected with InvalidArgument if it is not exactly 64 bytes. Existing call sites passing Vec<u8> keep compiling unchanged, since TryFrom<Vec<T>> for [T; N] covers the conversion.
Stored record bridges: prekey_structure_to_record and signed_prekey_structure_to_record (wacore/libsignal/src/store/record_helpers.rs) used to parse a stored structure’s keys just to validate them. They then rebuilt the structure from those same parsed keys. That reallocated both key fields — plus the signature, for the signed variant — on every prekey read. They now validate the caller’s structure and adopt it directly, via the new PreKeyRecord::from_storage and GenericSignedPreKey::from_stored_structure. Both normalize the stored public key exactly as deserialize always did, so a pre-0.7 33-byte row still heals on its next write.
SessionRecord::serialize_into: the scratch Vec<usize> carrying archived states’ encoded lengths between the sizing pass and the writing pass is now an 8-slot stack array, with a heap fallback for an archive deeper than that (previous_sessions holds 0–3 states in the common case).
The source PR measured these with divan::AllocProfiler: PreKeyBundle::new/clone and SessionRecord::serialize_into dropped to 0 allocations each (from 1), and the addon/ADV paths lost 1–8 allocations per call depending on the message type and prefix family. That measurement used temporary benches for the addon and ADV paths, since neither had bench coverage before; those benches were removed before merging and are not part of the repository. SessionRecord::serialize_into and PreKeyBundle are covered by the existing wacore/libsignal/benches/libsignal_benchmark.rs, which shows wall time flat within noise — expected, since AES-GCM, HKDF, and XEdDSA verification already dominate each call.
Location: wacore/src/secret_enc_addon.rs, wacore/src/adv.rs, wacore/libsignal/src/protocol/state/bundle.rs, wacore/libsignal/src/store/record_helpers.rs, wacore/libsignal/src/protocol/state/session.rs
Public API
Theclient.signal() accessor exposes low-level Signal protocol operations for direct use. This includes 1:1 and group encryption/decryption, session validation, session deletion, participant node creation, and device resolution.
See Signal API reference for full method documentation and examples.
Related Components
- Binary Protocol - How encrypted messages are serialized
- State Management - How session state is persisted
- WebSocket Handling - Transport layer for encrypted messages
- Signal API - Public API for Signal protocol operations
References
- Signal Protocol Specification
- libsignal Repository
- Source:
wacore/libsignal/src/protocol/ - Storage:
src/store/signal.rs,src/store/signal_adapter.rs,wacore/src/store/signal_cache.rs