Skip to content

fix: decrypt secretEncryptedMessage MESSAGE_EDIT envelopes#2690

Open
guilhermecugler wants to merge 1 commit into
WhiskeySockets:masterfrom
guilhermecugler:fix/decrypt-secret-encrypted-message-edit
Open

fix: decrypt secretEncryptedMessage MESSAGE_EDIT envelopes#2690
guilhermecugler wants to merge 1 commit into
WhiskeySockets:masterfrom
guilhermecugler:fix/decrypt-secret-encrypted-message-edit

Conversation

@guilhermecugler

@guilhermecugler guilhermecugler commented Jul 4, 2026

Copy link
Copy Markdown

Problem

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:

{
  "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 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. Two differences from the existing decrypt paths, cross-checked against the reference implementation in whatsmeow (msgsecret.go):

  • message edits are sealed without AAD;
  • the sender may have derived the key with either its LID or PN identity, so both are tried — GCM authentication cleanly 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 (isRealMessage), so they stop surfacing as phantom empty messages.

The messageSecret handed back by getMessage is also accepted as a base64 string, which is how JSON-backed stores commonly persist it.

Testing

  • 6 new unit tests in src/__tests__/Utils/process-message.test.ts: round-trip seal/decrypt, wrong-key rejection, LID/PN candidate fallback, base64 messageSecret, bare inner message wrapping, and missing-secret no-op (22/22 passing in the suite).
  • Validated against the live protocol: deployed on a production Evolution API v2.3.7 instance (baileys 7.0.0-rc.9 with this patch applied). Real edits from a phone were decrypted on the first candidate (LID identity), recognized as 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

    • Secret-encrypted message edits are now automatically decrypted before processing, improving support for hidden message updates.
    • Message edits can be resolved in-place when the original message secret is available, including fallback handling for alternate identities.
  • Bug Fixes

    • Messages containing secret-encrypted content are no longer treated as regular messages until they are properly unwrapped.
    • Decryption failures are handled gracefully, leaving the message unchanged when it can’t be decoded.

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.
@whiskeysockets-bot

Copy link
Copy Markdown
Contributor

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 works

If you’ve tested this PR, please comment below with:

Tested and working ✅

This helps us speed up the review and merge process.

📦 To test this PR locally:

# NPM
npm install @whiskeysockets/baileys@guilhermecugler/Baileys#fix/decrypt-secret-encrypted-message-edit

# Yarn (v2+)
yarn add @whiskeysockets/baileys@guilhermecugler/Baileys#fix/decrypt-secret-encrypted-message-edit

# PNPM
pnpm add @whiskeysockets/baileys@guilhermecugler/Baileys#fix/decrypt-secret-encrypted-message-edit

If you encounter any issues or have feedback, feel free to comment as well.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds decryption support for secret-encrypted MESSAGE_EDIT message envelopes. isRealMessage now excludes such content, new decryptMessageEdit and unwrapSecretEncryptedMessage functions decrypt and replace edit payloads in-place, and upsertMessage invokes the unwrap logic before processing. Tests cover the new decryption paths.

Changes

Secret-encrypted MESSAGE_EDIT decryption

Layer / File(s) Summary
Decryption core
src/Utils/process-message.ts
isRealMessage excludes secretEncryptedMessage content; new decryptMessageEdit performs HMAC-derived key AES-GCM decryption; new unwrapSecretEncryptedMessage validates envelopes, fetches secrets via getMessage, tries candidate JIDs, decodes, and replaces the message in-place.
Socket integration
src/Socket/chats.ts
Imports unwrapSecretEncryptedMessage and calls it in upsertMessage when incoming or edited messages contain secretEncryptedMessage, before the existing upsert flow continues.
Tests
src/__tests__/Utils/process-message.test.ts
Adds crypto/proto imports and a suite building sealed MESSAGE_EDIT payloads/envelopes, verifying round-trip decryption, wrong-key failure, in-place unwrap with identity fallback/base64 secrets, bare-message wrapping, and no-op when the secret is unavailable.

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
Loading

Poem

A secret edit, sealed up tight,
HMAC keys unlock the night,
GCM whispers, "decrypt with care,"
Candidate JIDs, tried with flair 🐇
In-place it shifts, the message clear,
Hop, hop, hooray — the plaintext's here! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: decrypting secretEncryptedMessage MESSAGE_EDIT envelopes.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/__tests__/Utils/process-message.test.ts (1)

204-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid new any in the test helpers.

These can be typed without weakening strict-mode coverage. As per coding guidelines, "Do not use any in new code. Existing anys 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 win

Make logger required for this async boundary.

unwrapSecretEncryptedMessage awaits getMessage and handles decrypt failures, but logger?: ILogger lets direct callers silently swallow those failures. Prefer logger: ILogger and pass a test stub where needed. As per coding guidelines, "Every code path that crosses an async boundary should accept a logger: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 731cd6b and 306dd16.

📒 Files selected for processing (3)
  • src/Socket/chats.ts
  • src/Utils/process-message.ts
  • src/__tests__/Utils/process-message.test.ts

Comment thread src/Socket/chats.ts
Comment on lines +1206 to +1210
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 })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment on lines +390 to +404
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
// 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.

Comment on lines +426 to +435
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
}
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Suggested change
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/Socket/chats.ts
}

const upsertMessage = ev.createBufferedFunction(async (msg: WAMessage, type: MessageUpsertType) => {
if (msg.message?.secretEncryptedMessage || msg.message?.editedMessage?.message?.secretEncryptedMessage) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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 {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants