From 0f10ebb1a16e6b613015bcfc7fe2fc433d554d88 Mon Sep 17 00:00:00 2001 From: Flavio Pulli Date: Fri, 24 Jul 2026 11:07:57 -0300 Subject: [PATCH] feat(whatsmeow): decrypt secret-encrypted message edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a phone edits a message in a @lid-addressed chat, WhatsApp delivers the edit as secretEncryptedMessage (MESSAGE_EDIT), encrypted with the original message's "message secret" — which this whatsmeow session already holds. Today the encrypted event is forwarded to the webhook as-is, so consumers learn that an edit happened but not what changed. Decrypt it with whatsmeow's official primitive (Client.DecryptSecretEncryptedMessage, native EncSecretMessageEdit support) and rewrite the event into the canonical cleartext shape (protocolMessage MESSAGE_EDIT + editedMessage) — the same shape BuildEdit produces — so webhook consumers handle phone edits and app edits identically. Fail-open: if the secret is missing (message predates the pairing) the event passes through unchanged. Battle-tested in production since 2026-07-21 against real phone edits. --- pkg/whatsmeow/service/secret_edit.go | 66 ++++++++++++++++++++++++++++ pkg/whatsmeow/service/whatsmeow.go | 5 +++ 2 files changed, 71 insertions(+) create mode 100644 pkg/whatsmeow/service/secret_edit.go diff --git a/pkg/whatsmeow/service/secret_edit.go b/pkg/whatsmeow/service/secret_edit.go new file mode 100644 index 00000000..87da66bf --- /dev/null +++ b/pkg/whatsmeow/service/secret_edit.go @@ -0,0 +1,66 @@ +package whatsmeow_service + +// Decryption of secret-encrypted message EDITS. +// +// When a phone edits a message in a @lid-addressed chat, WhatsApp delivers the +// edit as a `secretEncryptedMessage` (MESSAGE_EDIT): the new content is +// encrypted with the "message secret" of the ORIGINAL message — a secret this +// whatsmeow session already holds (it arrived inside the original message +// itself). Without this handling, the encrypted event is forwarded to the +// webhook as-is, so consumers learn THAT an edit happened but not WHAT changed. +// This closes the loop: decrypt with the official primitive +// (Client.DecryptSecretEncryptedMessage, which natively supports +// EncSecretMessageEdit) and rewrite the event into the CANONICAL cleartext +// shape (protocolMessage MESSAGE_EDIT + editedMessage) — the same shape +// BuildEdit produces — so webhook consumers handle both paths identically. +// +// Kept in its own file (plus a single line at the handler call-site) on +// purpose: rebasing over new evolution-go/whatsmeow releases stays trivial. + +import ( + "context" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +// resolveSecretEncryptedEdit detects a secret-encrypted edit +// (secretEncryptedMessage MESSAGE_EDIT) and, when decryption succeeds, +// rewrites evt.Message into the canonical cleartext shape. Fail-open: on any +// failure (missing secret, unsupported type, …) the event passes through +// untouched — behavior falls back to what it was before this change (webhook +// receives the encrypted payload). +func (mycli *MyClient) resolveSecretEncryptedEdit(evt *events.Message) { + enc := evt.Message.GetSecretEncryptedMessage() + if enc == nil || enc.GetSecretEncType() != waE2E.SecretEncryptedMessage_MESSAGE_EDIT { + return + } + decrypted, err := mycli.WAClient.DecryptSecretEncryptedMessage(context.Background(), evt) + if err != nil { + // Without the original message's secret (e.g. the message predates this + // pairing) there is nothing to do — the consumer still receives the + // cleartext target (targetMessageKey) and can at least flag the message + // as edited. + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn("[%s] secret-edit: failed to decrypt edit of %s: %v", mycli.userID, enc.GetTargetMessageKey().GetID(), err) + return + } + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] secret-edit: edit of %s decrypted — forwarding in cleartext", mycli.userID, enc.GetTargetMessageKey().GetID()) + // Phones encrypt the FULL edit envelope (the plaintext is already a + // protocolMessage MESSAGE_EDIT with editedMessage inside) — use it as-is; + // re-wrapping would double the nesting. The fallback covers a plaintext + // that carries only the new content. + if pm := decrypted.GetProtocolMessage(); pm != nil && + pm.GetType() == waE2E.ProtocolMessage_MESSAGE_EDIT && pm.GetEditedMessage() != nil { + evt.Message = decrypted + return + } + evt.Message = &waE2E.Message{ + ProtocolMessage: &waE2E.ProtocolMessage{ + Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(), + Key: enc.GetTargetMessageKey(), + EditedMessage: decrypted, + TimestampMS: proto.Int64(evt.Info.Timestamp.UnixMilli()), + }, + } +} diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 366f0edb..b6ea158e 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1137,6 +1137,11 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postMap["event"] = "Message" // Message received + // Secret-encrypted edits (secretEncryptedMessage MESSAGE_EDIT) become a + // cleartext protocolMessage when the original message's secret is known + // — see secret_edit.go. + mycli.resolveSecretEncryptedEdit(evt) + // Log message arrival with detailed info messageSize := "unknown" if evt.Message.GetDocumentMessage() != nil && evt.Message.GetDocumentMessage().FileLength != nil {