fix: decrypt secretEncryptedMessage MESSAGE_EDIT envelopes#2690
fix: decrypt secretEncryptedMessage MESSAGE_EDIT envelopes#2690guilhermecugler wants to merge 1 commit into
Conversation
Newer WhatsApp clients no longer send message edits as a plaintext protocolMessage: the edit arrives sealed inside a secretEncryptedMessage envelope (secretEncType MESSAGE_EDIT), encrypted with the original message's messageSecret. Baileys currently passes the opaque envelope through to consumers, so edits are impossible to read downstream. Decrypt the envelope in upsertMessage before it is emitted, deriving the key the same way poll votes and event responses already do (HKDF via double HMAC-SHA256 with the "Message Edit" use-case label), with two differences confirmed against the reference implementation in whatsmeow: message edits are sealed without AAD, and the sender may have derived the key with either its LID or PN identity, so both are tried (GCM authentication rejects the wrong one). On success the message is rewritten in place to the plaintext protocolMessage MESSAGE_EDIT shape every consumer already understands, so messages.upsert payloads and the existing messages.update emission keep working unchanged. Undecryptable envelopes are left untouched but no longer count as real messages, so they stop surfacing as phantom empty messages in chats. The messageSecret handed back by getMessage is also accepted as a base64 string, which is how JSON-backed stores commonly persist it.
|
Thanks for opening this pull request and contributing to the project! The next step is for the maintainers to review your changes. If everything looks good, it will be approved and merged into the main branch. In the meantime, anyone in the community is encouraged to test this pull request and provide feedback. ✅ How to confirm it worksIf you’ve tested this PR, please comment below with: This helps us speed up the review and merge process. 📦 To test this PR locally:If you encounter any issues or have feedback, feel free to comment as well. |
📝 WalkthroughWalkthroughThis PR adds decryption support for secret-encrypted MESSAGE_EDIT message envelopes. ChangesSecret-encrypted MESSAGE_EDIT decryption
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant UpsertMessage
participant unwrapSecretEncryptedMessage
participant getMessage
participant decryptMessageEdit
UpsertMessage->>unwrapSecretEncryptedMessage: detect secretEncryptedMessage in message/editedMessage
unwrapSecretEncryptedMessage->>getMessage: fetch original message
getMessage-->>unwrapSecretEncryptedMessage: return messageSecret (raw/base64)
unwrapSecretEncryptedMessage->>decryptMessageEdit: derive HMAC keys, try candidate JIDs, AES-GCM decrypt
decryptMessageEdit-->>unwrapSecretEncryptedMessage: decoded protocol message or error
alt decryption succeeds
unwrapSecretEncryptedMessage->>UpsertMessage: replace message.message in-place
else decryption fails
unwrapSecretEncryptedMessage->>UpsertMessage: leave message unchanged, log warning
end
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/__tests__/Utils/process-message.test.ts (1)
204-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAvoid new
anyin the test helpers.These can be typed without weakening strict-mode coverage. As per coding guidelines, "Do not use
anyin new code. Existinganys in tests are tolerated as warnings, not invitations." <coding_guidelines>Proposed fix
- const sealEdit = (innerMessage: any, jid = authorJid, key: Buffer = msgEncKey) => { + const sealEdit = (innerMessage: proto.IMessage, jid = authorJid, key: Buffer = msgEncKey) => {- const creds: any = { me: { id: '5513900000000:3@s.whatsapp.net', lid: '999@lid' } } + const creds = { + me: { id: '5513900000000:3@s.whatsapp.net', lid: '999@lid' } + } as Parameters<typeof unwrapSecretEncryptedMessage>[1]['creds']Also applies to: 265-265
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/__tests__/Utils/process-message.test.ts` at line 204, The test helpers introduce new any types and should be tightened to preserve strict-mode coverage. Update the helper signatures in process-message.test.ts, especially sealEdit (and the other helper at the referenced location), to use explicit or inferred message types instead of any. Keep the helper APIs the same while replacing any with the appropriate existing message/interface types from the test setup or a shared test type.Source: Coding guidelines
src/Utils/process-message.ts (1)
343-353: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMake
loggerrequired for this async boundary.
unwrapSecretEncryptedMessageawaitsgetMessageand handles decrypt failures, butlogger?: ILoggerlets direct callers silently swallow those failures. Preferlogger: ILoggerand pass a test stub where needed. As per coding guidelines, "Every code path that crosses an async boundary should accept alogger: ILogger(pino-compatible)." <coding_guidelines>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/Utils/process-message.ts` around lines 343 - 353, `unwrapSecretEncryptedMessage` currently allows `logger` to be optional even though it crosses an async boundary and handles decrypt/getMessage failures; make the `logger` parameter required in the function signature and update all call sites to pass a pino-compatible `ILogger`, including tests with a stub logger where needed. Use the `unwrapSecretEncryptedMessage` helper and its surrounding context in `process-message.ts` to locate the change, and keep the failure handling logic intact while ensuring no direct caller can omit logging.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/Socket/chats.ts`:
- Around line 1206-1210: The secret-message unwrap guard in chats.ts is too
narrow and misses secret edit envelopes nested inside other normalized wrappers.
Remove the pre-filter around unwrapSecretEncryptedMessage in the message
handling path and let unwrapSecretEncryptedMessage(msg, { creds:
authState.creds, getMessage, logger }) run for the incoming message so it can
normalize and early-return itself. Keep the surrounding flow in the chat message
processing code unchanged, but rely on the helper’s own checks instead of only
msg.message?.secretEncryptedMessage or
editedMessage.message?.secretEncryptedMessage.
In `@src/Utils/process-message.ts`:
- Around line 426-435: The protocol-message normalization in process-message.ts
only wraps payloads when protocolMessage is missing, so a decrypted payload with
an existing protocolMessage can bypass validation. Update the MESSAGE_EDIT
handling around the decoded/protocolMessage block to reject any non-MESSAGE_EDIT
protocolMessage types and ensure the embedded key is normalized to targetKey
before passing it downstream. Keep the fix localized to the decode/validation
path that constructs or preserves the protocolMessage object.
- Around line 390-404: The candidate JID setup in process-message.ts eagerly
dereferences creds.me!.id even for non-fromMe messages, which breaks incoming
edit handling when creds.me is absent. Update the candidate construction around
meIdNormalised so creds.me is only accessed when message.key.fromMe is true, and
keep the incoming-edit paths using participant/remoteJid and targetKey.remoteJid
unchanged. Use the existing candidates array logic and jidNormalizedUser helper
to avoid introducing the self JID lookup unless it is actually needed.
---
Nitpick comments:
In `@src/__tests__/Utils/process-message.test.ts`:
- Line 204: The test helpers introduce new any types and should be tightened to
preserve strict-mode coverage. Update the helper signatures in
process-message.test.ts, especially sealEdit (and the other helper at the
referenced location), to use explicit or inferred message types instead of any.
Keep the helper APIs the same while replacing any with the appropriate existing
message/interface types from the test setup or a shared test type.
In `@src/Utils/process-message.ts`:
- Around line 343-353: `unwrapSecretEncryptedMessage` currently allows `logger`
to be optional even though it crosses an async boundary and handles
decrypt/getMessage failures; make the `logger` parameter required in the
function signature and update all call sites to pass a pino-compatible
`ILogger`, including tests with a stub logger where needed. Use the
`unwrapSecretEncryptedMessage` helper and its surrounding context in
`process-message.ts` to locate the change, and keep the failure handling logic
intact while ensuring no direct caller can omit logging.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: a79f6fa4-d0ed-4019-b837-4f7b4fd6cb3c
📒 Files selected for processing (3)
src/Socket/chats.tssrc/Utils/process-message.tssrc/__tests__/Utils/process-message.test.ts
| if (msg.message?.secretEncryptedMessage || msg.message?.editedMessage?.message?.secretEncryptedMessage) { | ||
| // message edits from newer clients arrive sealed with the original | ||
| // message's secret — decrypt before anything downstream sees the message | ||
| await unwrapSecretEncryptedMessage(msg, { creds: authState.creds, getMessage, logger }) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Don’t pre-filter with a narrower wrapper check.
unwrapSecretEncryptedMessage already normalizes content and returns early, but this guard only catches top-level and editedMessage wrappers. Secret edit envelopes inside other normalized wrappers are emitted unchanged.
Proposed fix
- if (msg.message?.secretEncryptedMessage || msg.message?.editedMessage?.message?.secretEncryptedMessage) {
- // message edits from newer clients arrive sealed with the original
- // message's secret — decrypt before anything downstream sees the message
- await unwrapSecretEncryptedMessage(msg, { creds: authState.creds, getMessage, logger })
- }
+ // message edits from newer clients arrive sealed with the original
+ // message's secret — decrypt before anything downstream sees the message
+ await unwrapSecretEncryptedMessage(msg, { creds: authState.creds, getMessage, logger })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (msg.message?.secretEncryptedMessage || msg.message?.editedMessage?.message?.secretEncryptedMessage) { | |
| // message edits from newer clients arrive sealed with the original | |
| // message's secret — decrypt before anything downstream sees the message | |
| await unwrapSecretEncryptedMessage(msg, { creds: authState.creds, getMessage, logger }) | |
| } | |
| // message edits from newer clients arrive sealed with the original | |
| // message's secret — decrypt before anything downstream sees the message | |
| await unwrapSecretEncryptedMessage(msg, { creds: authState.creds, getMessage, logger }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/Socket/chats.ts` around lines 1206 - 1210, The secret-message unwrap
guard in chats.ts is too narrow and misses secret edit envelopes nested inside
other normalized wrappers. Remove the pre-filter around
unwrapSecretEncryptedMessage in the message handling path and let
unwrapSecretEncryptedMessage(msg, { creds: authState.creds, getMessage, logger
}) run for the incoming message so it can normalize and early-return itself.
Keep the surrounding flow in the chat message processing code unchanged, but
rely on the helper’s own checks instead of only
msg.message?.secretEncryptedMessage or
editedMessage.message?.secretEncryptedMessage.
| // the sender may have derived the key with either its LID or PN identity — | ||
| // try both (GCM authentication rejects the wrong one), like whatsmeow does | ||
| const meIdNormalised = jidNormalizedUser(creds.me!.id) | ||
| const candidates = [ | ||
| ...(message.key.fromMe | ||
| ? [meIdNormalised, creds.me?.lid] | ||
| : [ | ||
| message.key.participant || message.key.remoteJid, | ||
| message.key.participantAlt || message.key.remoteJidAlt | ||
| ]), | ||
| targetKey.remoteJid | ||
| ] | ||
| .filter((jid): jid is string => !!jid) | ||
| .map(jid => jidNormalizedUser(jid)) | ||
| .filter((jid, i, arr) => arr.indexOf(jid) === i) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Avoid dereferencing creds.me for incoming edits.
Line 392 always evaluates creds.me!.id, even when message.key.fromMe is false and the self JID candidates are not needed. If creds.me is unavailable, decryptable incoming edits are left wrapped.
Proposed fix
- const meIdNormalised = jidNormalizedUser(creds.me!.id)
const candidates = [
...(message.key.fromMe
- ? [meIdNormalised, creds.me?.lid]
+ ? [creds.me?.id, creds.me?.lid]
: [
message.key.participant || message.key.remoteJid,
message.key.participantAlt || message.key.remoteJidAlt
]),
targetKey.remoteJid
]
.filter((jid): jid is string => !!jid)
.map(jid => jidNormalizedUser(jid))
+ .filter((jid): jid is string => !!jid)
.filter((jid, i, arr) => arr.indexOf(jid) === i)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // the sender may have derived the key with either its LID or PN identity — | |
| // try both (GCM authentication rejects the wrong one), like whatsmeow does | |
| const meIdNormalised = jidNormalizedUser(creds.me!.id) | |
| const candidates = [ | |
| ...(message.key.fromMe | |
| ? [meIdNormalised, creds.me?.lid] | |
| : [ | |
| message.key.participant || message.key.remoteJid, | |
| message.key.participantAlt || message.key.remoteJidAlt | |
| ]), | |
| targetKey.remoteJid | |
| ] | |
| .filter((jid): jid is string => !!jid) | |
| .map(jid => jidNormalizedUser(jid)) | |
| .filter((jid, i, arr) => arr.indexOf(jid) === i) | |
| // the sender may have derived the key with either its LID or PN identity — | |
| // try both (GCM authentication rejects the wrong one), like whatsmeow does | |
| const candidates = [ | |
| ...(message.key.fromMe | |
| ? [creds.me?.id, creds.me?.lid] | |
| : [ | |
| message.key.participant || message.key.remoteJid, | |
| message.key.participantAlt || message.key.remoteJidAlt | |
| ]), | |
| targetKey.remoteJid | |
| ] | |
| .filter((jid): jid is string => !!jid) | |
| .map(jid => jidNormalizedUser(jid)) | |
| .filter((jid): jid is string => !!jid) | |
| .filter((jid, i, arr) => arr.indexOf(jid) === i) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/Utils/process-message.ts` around lines 390 - 404, The candidate JID setup
in process-message.ts eagerly dereferences creds.me!.id even for non-fromMe
messages, which breaks incoming edit handling when creds.me is absent. Update
the candidate construction around meIdNormalised so creds.me is only accessed
when message.key.fromMe is true, and keep the incoming-edit paths using
participant/remoteJid and targetKey.remoteJid unchanged. Use the existing
candidates array logic and jidNormalizedUser helper to avoid introducing the
self JID lookup unless it is actually needed.
| if (!decoded.protocolMessage) { | ||
| // normalise to the plaintext edit shape consumers already understand | ||
| decoded = proto.Message.fromObject({ | ||
| protocolMessage: { | ||
| key: targetKey, | ||
| type: proto.Message.ProtocolMessage.Type.MESSAGE_EDIT, | ||
| editedMessage: decoded | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Validate decrypted payloads before emitting protocol actions.
A decrypted payload that already contains protocolMessage is passed through unchanged, so a non-MESSAGE_EDIT type or mismatched key can reach downstream protocol handling from a MESSAGE_EDIT envelope. Reject non-edit protocol messages and normalize the key to targetKey.
Proposed fix
- if (!decoded.protocolMessage) {
+ const editType = proto.Message.ProtocolMessage.Type.MESSAGE_EDIT
+ if (decoded.protocolMessage) {
+ if (decoded.protocolMessage.type !== editType || !decoded.protocolMessage.editedMessage) {
+ throw new Error('message edit: decrypted payload is not a MESSAGE_EDIT protocolMessage')
+ }
+
+ decoded.protocolMessage.key = targetKey
+ } else {
// normalise to the plaintext edit shape consumers already understand
decoded = proto.Message.fromObject({
protocolMessage: {
key: targetKey,
- type: proto.Message.ProtocolMessage.Type.MESSAGE_EDIT,
+ type: editType,
editedMessage: decoded
}
})
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!decoded.protocolMessage) { | |
| // normalise to the plaintext edit shape consumers already understand | |
| decoded = proto.Message.fromObject({ | |
| protocolMessage: { | |
| key: targetKey, | |
| type: proto.Message.ProtocolMessage.Type.MESSAGE_EDIT, | |
| editedMessage: decoded | |
| } | |
| }) | |
| } | |
| const editType = proto.Message.ProtocolMessage.Type.MESSAGE_EDIT | |
| if (decoded.protocolMessage) { | |
| if (decoded.protocolMessage.type !== editType || !decoded.protocolMessage.editedMessage) { | |
| throw new Error('message edit: decrypted payload is not a MESSAGE_EDIT protocolMessage') | |
| } | |
| decoded.protocolMessage.key = targetKey | |
| } else { | |
| // normalise to the plaintext edit shape consumers already understand | |
| decoded = proto.Message.fromObject({ | |
| protocolMessage: { | |
| key: targetKey, | |
| type: editType, | |
| editedMessage: decoded | |
| } | |
| }) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/Utils/process-message.ts` around lines 426 - 435, The protocol-message
normalization in process-message.ts only wraps payloads when protocolMessage is
missing, so a decrypted payload with an existing protocolMessage can bypass
validation. Update the MESSAGE_EDIT handling around the decoded/protocolMessage
block to reject any non-MESSAGE_EDIT protocolMessage types and ensure the
embedded key is normalized to targetKey before passing it downstream. Keep the
fix localized to the decode/validation path that constructs or preserves the
protocolMessage object.
There was a problem hiding this comment.
3 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/Socket/chats.ts">
<violation number="1" location="src/Socket/chats.ts:1206">
P2: The `unwrapSecretEncryptedMessage` gate in `upsertMessage` uses raw property access instead of matching the normalization that `unwrapSecretEncryptedMessage` performs internally. If a `secretEncryptedMessage` envelope arrives inside a wrapper that `normalizeMessageContent` handles—such as `ephemeralMessage` or `viewOnceMessage`—the guard will skip decryption entirely, leaving those edits undecrypted and surfacing as phantom empty messages again. Since `unwrapSecretEncryptedMessage` already validates `secretEncType`, `encPayload`, and `encIv` before doing any real work, the safest and most protocol-correct approach is to remove the narrow guard and call it unconditionally (or at least guard with `normalizeMessageContent`).</violation>
</file>
<file name="src/Utils/process-message.ts">
<violation number="1" location="src/Utils/process-message.ts:182">
P2: `isRealMessage` now blanket-excludes any message containing `secretEncryptedMessage`, which suppresses chat-update semantics for all secret-encrypted envelopes rather than only the `MESSAGE_EDIT` type this PR targets. If WhatsApp introduces other secret-encrypted types that wrap user-visible content, or if `isRealMessage` is evaluated before `unwrapSecretEncryptedMessage` runs, those messages will silently be treated as non-real (no unread increment, no timestamp update). Consider scoping the exclusion to the specific `secretEncType` or to undecryptable/failed-decrypt state instead of a content-type blanket ban in the central classifier.</violation>
<violation number="2" location="src/Utils/process-message.ts:426">
P2: When `decoded.protocolMessage` already exists, it's passed through without verifying that it's actually a `MESSAGE_EDIT` type. A malformed or unexpected decrypted payload with a different protocol message type could reach downstream protocol handling from a `MESSAGE_EDIT` envelope. Consider validating that `decoded.protocolMessage.type === MESSAGE_EDIT` and that `editedMessage` is present, rejecting or logging otherwise.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| } | ||
|
|
||
| const upsertMessage = ev.createBufferedFunction(async (msg: WAMessage, type: MessageUpsertType) => { | ||
| if (msg.message?.secretEncryptedMessage || msg.message?.editedMessage?.message?.secretEncryptedMessage) { |
There was a problem hiding this comment.
P2: The unwrapSecretEncryptedMessage gate in upsertMessage uses raw property access instead of matching the normalization that unwrapSecretEncryptedMessage performs internally. If a secretEncryptedMessage envelope arrives inside a wrapper that normalizeMessageContent handles—such as ephemeralMessage or viewOnceMessage—the guard will skip decryption entirely, leaving those edits undecrypted and surfacing as phantom empty messages again. Since unwrapSecretEncryptedMessage already validates secretEncType, encPayload, and encIv before doing any real work, the safest and most protocol-correct approach is to remove the narrow guard and call it unconditionally (or at least guard with normalizeMessageContent).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Socket/chats.ts, line 1206:
<comment>The `unwrapSecretEncryptedMessage` gate in `upsertMessage` uses raw property access instead of matching the normalization that `unwrapSecretEncryptedMessage` performs internally. If a `secretEncryptedMessage` envelope arrives inside a wrapper that `normalizeMessageContent` handles—such as `ephemeralMessage` or `viewOnceMessage`—the guard will skip decryption entirely, leaving those edits undecrypted and surfacing as phantom empty messages again. Since `unwrapSecretEncryptedMessage` already validates `secretEncType`, `encPayload`, and `encIv` before doing any real work, the safest and most protocol-correct approach is to remove the narrow guard and call it unconditionally (or at least guard with `normalizeMessageContent`).</comment>
<file context>
@@ -1203,6 +1203,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
const upsertMessage = ev.createBufferedFunction(async (msg: WAMessage, type: MessageUpsertType) => {
+ if (msg.message?.secretEncryptedMessage || msg.message?.editedMessage?.message?.secretEncryptedMessage) {
+ // message edits from newer clients arrive sealed with the original
+ // message's secret — decrypt before anything downstream sees the message
</file context>
| !normalizedContent?.reactionMessage && | ||
| !normalizedContent?.pollUpdateMessage | ||
| !normalizedContent?.pollUpdateMessage && | ||
| !normalizedContent?.secretEncryptedMessage |
There was a problem hiding this comment.
P2: isRealMessage now blanket-excludes any message containing secretEncryptedMessage, which suppresses chat-update semantics for all secret-encrypted envelopes rather than only the MESSAGE_EDIT type this PR targets. If WhatsApp introduces other secret-encrypted types that wrap user-visible content, or if isRealMessage is evaluated before unwrapSecretEncryptedMessage runs, those messages will silently be treated as non-real (no unread increment, no timestamp update). Consider scoping the exclusion to the specific secretEncType or to undecryptable/failed-decrypt state instead of a content-type blanket ban in the central classifier.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Utils/process-message.ts, line 182:
<comment>`isRealMessage` now blanket-excludes any message containing `secretEncryptedMessage`, which suppresses chat-update semantics for all secret-encrypted envelopes rather than only the `MESSAGE_EDIT` type this PR targets. If WhatsApp introduces other secret-encrypted types that wrap user-visible content, or if `isRealMessage` is evaluated before `unwrapSecretEncryptedMessage` runs, those messages will silently be treated as non-real (no unread increment, no timestamp update). Consider scoping the exclusion to the specific `secretEncType` or to undecryptable/failed-decrypt state instead of a content-type blanket ban in the central classifier.</comment>
<file context>
@@ -178,7 +178,8 @@ export const isRealMessage = (message: WAMessage) => {
!normalizedContent?.reactionMessage &&
- !normalizedContent?.pollUpdateMessage
+ !normalizedContent?.pollUpdateMessage &&
+ !normalizedContent?.secretEncryptedMessage
)
}
</file context>
| throw lastError | ||
| } | ||
|
|
||
| if (!decoded.protocolMessage) { |
There was a problem hiding this comment.
P2: When decoded.protocolMessage already exists, it's passed through without verifying that it's actually a MESSAGE_EDIT type. A malformed or unexpected decrypted payload with a different protocol message type could reach downstream protocol handling from a MESSAGE_EDIT envelope. Consider validating that decoded.protocolMessage.type === MESSAGE_EDIT and that editedMessage is present, rejecting or logging otherwise.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Utils/process-message.ts, line 426:
<comment>When `decoded.protocolMessage` already exists, it's passed through without verifying that it's actually a `MESSAGE_EDIT` type. A malformed or unexpected decrypted payload with a different protocol message type could reach downstream protocol handling from a `MESSAGE_EDIT` envelope. Consider validating that `decoded.protocolMessage.type === MESSAGE_EDIT` and that `editedMessage` is present, rejecting or logging otherwise.</comment>
<file context>
@@ -290,6 +291,160 @@ export function decryptEventResponse(
+ throw lastError
+ }
+
+ if (!decoded.protocolMessage) {
+ // normalise to the plaintext edit shape consumers already understand
+ decoded = proto.Message.fromObject({
</file context>
| if (!decoded.protocolMessage) { | |
| const editType = proto.Message.ProtocolMessage.Type.MESSAGE_EDIT | |
| if (decoded.protocolMessage) { | |
| if (decoded.protocolMessage.type !== editType || !decoded.protocolMessage.editedMessage) { | |
| throw new Error('message edit: decrypted payload is not a MESSAGE_EDIT protocolMessage') | |
| } | |
| decoded.protocolMessage.key = targetKey | |
| } else { |
Problem
Newer WhatsApp clients no longer send message edits as a plaintext
protocolMessage. The edit arrives sealed inside asecretEncryptedMessageenvelope (secretEncType: MESSAGE_EDIT), encrypted with the original message'smessageSecret:{ "secretEncryptedMessage": { "targetMessageKey": { "remoteJid": "211459040641230@lid", "fromMe": true, "id": "3EB0C8712CC1E53869085F" }, "encPayload": "...", "encIv": "...", "secretEncType": 2 } }Baileys currently passes the opaque envelope through to consumers, so edits are impossible to read downstream — they surface as phantom empty messages and the edited content is lost. Downstream projects are hitting this too (e.g. evolution-api, where edits stopped producing any usable event).
Solution
Decrypt the envelope in
upsertMessagebefore it is emitted, deriving the key the same way poll votes and event responses already do (HKDF via double HMAC-SHA256), with theMessage Edituse-case label. Two differences from the existing decrypt paths, cross-checked against the reference implementation in whatsmeow (msgsecret.go):On success the message is rewritten in place to the plaintext
protocolMessageMESSAGE_EDITshape every consumer already understands, somessages.upsertpayloads and the existingmessages.updateemission keep working unchanged. Undecryptable envelopes are left untouched but no longer count as real messages (isRealMessage), so they stop surfacing as phantom empty messages.The
messageSecrethanded back bygetMessageis also accepted as a base64 string, which is how JSON-backed stores commonly persist it.Testing
src/__tests__/Utils/process-message.test.ts: round-trip seal/decrypt, wrong-key rejection, LID/PN candidate fallback, base64messageSecret, bare inner message wrapping, and missing-secret no-op (22/22 passing in the suite).MESSAGE_EDIT, and flowed through to the consumer webhook with the edited text — previously the same edits arrived as undecryptable envelopes.Related issues
Downstream reports of this exact behavior (edits arriving only as an opaque
secretEncryptedMessage, with no way to recover the edited content):Summary by CodeRabbit
New Features
Bug Fixes