-
-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Description
Is the feature you are looking for already implemented in the library?
- I have reviewed the implemented features.
Is your "solution" already implemented in an opened pull request?
- I have reviewed the existing pull requests.
Is your feature request related to a problem? Please describe.
Messages arriving as type: "ciphertext" in Store.Msg frequently never decrypt. The change:type listener set up in Client.js never fires, and the message is silently lost from the application's perspective.
Root cause analysis through extensive diagnostic logging:
The issue stems from broken Signal Protocol sessions between the companion device (Puppeteer browser) and certain senders. There are two distinct failure modes:
-
Missing Sender Key Distribution Messages (groups): When a companion device misses the SKDM for a group conversation (e.g., due to temporary disconnection), it cannot decrypt any subsequent messages from that sender in that group. WhatsApp Web has no mechanism to request the missing SKDM.
-
Broken pairwise sessions (DMs): When a sender changes devices, reinstalls WhatsApp, or their encryption keys rotate, the companion device's Signal session becomes stale. WhatsApp Web sends a retry receipt, but the renegotiation often fails silently.
Evidence from production logging:
- Tracked ciphertext messages with 30s/60s/120s rechecks
- Messages remain
type: "ciphertext"inStore.Msgeven after 120 seconds Store.Msg.get(msgId)confirms the model is unchanged - no model replacement occursStore.Msg.getMessagesById([msgId])also returns the same ciphertext modelisNewMsgremainstrueon the original modelchange:typegenuinely never fires- Failures are sender-specific: certain
@lidaddresses consistently fail while others work fine e2e_notificationevents correlate with failing senders (encryption key changes)
What the library currently does (Client.js):
if (msg.type === 'ciphertext') {
msg.once('change:type', (_msg) => {
onAddMessageEvent(getMessageModel(_msg));
});
}There is no timeout, no retry, and no fallback. If change:type never fires, the message is permanently lost.
Describe the solution you'd like.
Implement a Peer Data Operation (PDO) type 4 retry mechanism, similar to what Baileys implements successfully.
WhatsApp's protocol supports peerDataOperationRequestType = HISTORY_SYNC_ON_DEMAND (type 4), which asks the sender's primary device to re-send the message content in decrypted form. This completely bypasses the broken Signal Protocol session.
The function already exists in the WhatsApp Web Store:
Store.HistorySync.sendPeerDataOperationRequest(
4, // HISTORY_SYNC_ON_DEMAND
{
historySyncOnDemandRequest: {
chatJid: chatWid,
oldestMsgId: msgId,
oldestMsgFromMe: false,
onDemandMsgCount: 1,
oldestMsgTimestampMs: timestamp * 1000
}
}
);This is the same function the library already uses indirectly (type 3 is used for history sync). Type 4 simply requests on-demand re-delivery of specific messages.
Baileys implements three retry mechanisms that whatsapp-web.js currently lacks:
- Signal retry receipt (
retryRequestmessage to sender) - PDO type 4 (ask phone to re-send decrypted content)
- Session recreation (rebuild Signal session from scratch)
Proposed implementation:
if (msg.type === 'ciphertext') {
const msgId = msg.id._serialized;
msg.once('change:type', (_msg) => {
clearTimeout(retryTimeout);
onAddMessageEvent(getMessageModel(_msg));
});
// After N seconds, attempt PDO type 4 recovery
const retryTimeout = setTimeout(async () => {
try {
await window.Store.HistorySync.sendPeerDataOperationRequest(4, {
historySyncOnDemandRequest: {
chatJid: msg.id.remote,
oldestMsgId: msg.id.id,
oldestMsgFromMe: false,
onDemandMsgCount: 1,
oldestMsgTimestampMs: msg.t * 1000
}
});
} catch (e) {
// Emit error event so applications can handle it
}
}, 30000);
}Describe an alternate solution if you have one.
Short-term: Emit a message_ciphertext_timeout event after a configurable timeout, so applications can at least detect and log lost messages. This is what I've implemented in my fork for diagnostics.
Medium-term: Implement Signal retry receipts (like Baileys' sendRetryRequest), which asks the sender to re-encrypt and re-send. This works for pairwise sessions but not for missing group SKDMs.
Long-term: Consider adopting the multi-device noise protocol approach that Baileys uses, which handles session management more robustly and doesn't rely on Puppeteer/browser automation.
How to reproduce and verify
Setup - add diagnostic logging:
const { Client, LocalAuth } = require('whatsapp-web.js');
const client = new Client({
authStrategy: new LocalAuth(),
puppeteer: { headless: true }
});
// Track ciphertext messages
client.on('message_ciphertext', (msg) => {
const msgId = msg.id._serialized;
console.log(`[CIPHERTEXT] Received: ${msgId} from ${msg.from} at ${new Date().toISOString()}`);
// Check Store state after 30s, 60s, and 120s
for (const delay of [30000, 60000, 120000]) {
setTimeout(async () => {
const state = await client.pupPage.evaluate((id) => {
const msg = window.Store.Msg.get(id);
if (!msg) return { inStore: false };
return {
inStore: true,
type: msg.type,
isNewMsg: msg.isNewMsg,
body: (msg.body || '').substring(0, 30)
};
}, msgId);
console.log(`[CIPHERTEXT] After ${delay/1000}s: ${JSON.stringify(state)}`);
}, delay);
}
});
client.on('message', (msg) => {
console.log(`[MESSAGE] ${msg.type} from ${msg.from}`);
});
client.initialize();To trigger the issue:
- Run the code above on a linked companion device
- Have a contact who recently changed devices, reinstalled WhatsApp, or rotated encryption keys send a message
- Observe
message_ciphertextfires butmessagenever fires for that message - The 30s/60s/120s rechecks will all show
type: "ciphertext"- the message never decrypts
What you will see in the logs:
[CIPHERTEXT] Received: false_123456@lid_3BAAEBE056E04EE from 123456@lid
[CIPHERTEXT] After 30s: {"inStore":true,"type":"ciphertext","isNewMsg":true,"body":""}
[CIPHERTEXT] After 60s: {"inStore":true,"type":"ciphertext","isNewMsg":true,"body":""}
[CIPHERTEXT] After 120s: {"inStore":true,"type":"ciphertext","isNewMsg":true,"body":""}
Note: the [MESSAGE] event never fires for this message - it is permanently lost.
Important notes:
- This does NOT happen for every message. It is sender-specific and depends on the state of the Signal Protocol session.
- Once a session is broken with a specific sender, most messages from that sender will fail (not all - some may still decrypt).
- The issue is more common with
@lid(Linked ID) addresses in multi-device setups. - Restarting the client does NOT fix the issue - the broken session persists.
Additional Context
How I discovered this: I noticed certain messages silently disappearing - they would show in the WhatsApp app on the phone but never trigger the message event in the library. I added diagnostic logging at Store.Msg.on('add') to track every incoming message, then added 30s/60s/120s rechecks to verify whether the ciphertext ever resolves. The data clearly showed messages permanently stuck as ciphertext.
Baileys reference implementation:
- Baileys message retry handling - implements
sendRetryRequestand PDO type 4 - Baileys socket handling - handles
peerDataOperationRequestResponseMessageresponses
Related issues:
- Message body is empty ("") with type "ciphertext" even though message is visible in WhatsApp Web #3920 (same symptom reported, but without root cause analysis)
- getContactById intermittently throws when Store.Contact.find() returns undefined or fails #127054 (
getContactByIdintermittently throws whenStore.Contact.find()returns undefined)
This is not an edge case - in my testing, the majority of ciphertext messages from affected senders never decrypt, making the library unreliable for receiving messages in production environments.