From 3bea1f34fa0ca67f9f869d5f33a494af1d9fff30 Mon Sep 17 00:00:00 2001 From: Caio-HD Date: Mon, 3 Aug 2026 13:30:34 -0300 Subject: [PATCH 1/2] fix(message): decrypt secretEncryptedMessage MESSAGE_EDIT envelopes WhatsApp now seals message edits in a secretEncryptedMessage envelope instead of a plaintext protocolMessage, so edits reached consumers as an opaque blob with no readable text. Decrypt it with whatsmeow's DecryptSecretEncryptedMessage and rewrite the message in place to the protocolMessage{MESSAGE_EDIT} shape consumers already handle. Every failure path forwards the envelope untouched, so a message is never dropped. --- pkg/whatsmeow/service/secret_edit.go | 76 +++++++++++++++ pkg/whatsmeow/service/secret_edit_test.go | 108 ++++++++++++++++++++++ pkg/whatsmeow/service/whatsmeow.go | 4 + 3 files changed, 188 insertions(+) create mode 100644 pkg/whatsmeow/service/secret_edit.go create mode 100644 pkg/whatsmeow/service/secret_edit_test.go diff --git a/pkg/whatsmeow/service/secret_edit.go b/pkg/whatsmeow/service/secret_edit.go new file mode 100644 index 00000000..ee157f87 --- /dev/null +++ b/pkg/whatsmeow/service/secret_edit.go @@ -0,0 +1,76 @@ +package whatsmeow_service + +import ( + "context" + + "go.mau.fi/whatsmeow/proto/waCommon" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +// isSecretEncryptedEdit reports whether the message is an edit sealed in a +// secretEncryptedMessage envelope. +// +// WhatsApp no longer sends message edits as a plaintext protocolMessage: the new content is +// encrypted with a key derived from the target message secret. Without decrypting it, the +// envelope reaches consumers as an opaque blob with no text at all. +func isSecretEncryptedEdit(message *waE2E.Message) bool { + enc := message.GetSecretEncryptedMessage() + return enc != nil && enc.GetSecretEncType() == waE2E.SecretEncryptedMessage_MESSAGE_EDIT +} + +// buildEditProtocolMessage rebuilds the plaintext shape consumers already handle — +// protocolMessage{type: MESSAGE_EDIT, key: , editedMessage: } — so the +// decrypted edit needs no new contract downstream. Returns nil when there is nothing to +// rebuild, letting callers keep the original envelope. +func buildEditProtocolMessage(target *waCommon.MessageKey, decrypted *waE2E.Message, timestampMS int64) *waE2E.Message { + if target == nil || decrypted == nil { + return nil + } + + protocolMessage := &waE2E.ProtocolMessage{ + Key: target, + Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(), + EditedMessage: decrypted, + } + + if timestampMS > 0 { + protocolMessage.TimestampMS = proto.Int64(timestampMS) + } + + return &waE2E.Message{ProtocolMessage: protocolMessage} +} + +// unwrapSecretEncryptedEdit decrypts a MESSAGE_EDIT envelope in place, so message typing, the +// webhook payload and persistence all see the edited text instead of the sealed envelope. +// +// Every failure path leaves the event untouched on purpose: forwarding the original envelope +// is what happens today, while dropping the event would lose the edit signal entirely. +func (mycli *MyClient) unwrapSecretEncryptedEdit(evt *events.Message) { + if evt == nil || !isSecretEncryptedEdit(evt.Message) { + return + } + + client := mycli.clientPointer[mycli.userID] + if client == nil { + return + } + + targetKey := evt.Message.GetSecretEncryptedMessage().GetTargetMessageKey() + + decrypted, err := client.DecryptSecretEncryptedMessage(context.Background(), evt) + if err != nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogError("[%s] Failed to decrypt edited message %s: %v", mycli.userID, evt.Info.ID, err) + return + } + + rebuilt := buildEditProtocolMessage(targetKey, decrypted, evt.Info.Timestamp.UnixMilli()) + if rebuilt == nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn("[%s] Decrypted edit %s has no target message key, forwarding envelope as-is", mycli.userID, evt.Info.ID) + return + } + + evt.Message = rebuilt + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Decrypted edited message %s targeting %s", mycli.userID, evt.Info.ID, targetKey.GetID()) +} diff --git a/pkg/whatsmeow/service/secret_edit_test.go b/pkg/whatsmeow/service/secret_edit_test.go new file mode 100644 index 00000000..92007dcc --- /dev/null +++ b/pkg/whatsmeow/service/secret_edit_test.go @@ -0,0 +1,108 @@ +package whatsmeow_service + +import ( + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/utils" + "go.mau.fi/whatsmeow/proto/waCommon" + "go.mau.fi/whatsmeow/proto/waE2E" +) + +func secretEditEnvelope(encType waE2E.SecretEncryptedMessage_SecretEncType) *waE2E.Message { + return &waE2E.Message{ + SecretEncryptedMessage: &waE2E.SecretEncryptedMessage{ + TargetMessageKey: &waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")}, + EncPayload: []byte("ciphertext"), + EncIV: []byte("iv"), + SecretEncType: encType.Enum(), + }, + } +} + +func TestIsSecretEncryptedEdit(t *testing.T) { + if !isSecretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT)) { + t.Fatal("expected MESSAGE_EDIT envelope to be detected as an edit") + } + + if isSecretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_POLL_EDIT)) { + t.Fatal("expected POLL_EDIT envelope not to be treated as a message edit") + } + + if isSecretEncryptedEdit(&waE2E.Message{Conversation: stringPtr("plain text")}) { + t.Fatal("expected a plain message not to be treated as an edit") + } + + if isSecretEncryptedEdit(nil) { + t.Fatal("expected a nil message not to be treated as an edit") + } +} + +func TestBuildEditProtocolMessageRestoresPlaintextShape(t *testing.T) { + target := &waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")} + decrypted := &waE2E.Message{Conversation: stringPtr("corrected text")} + + rebuilt := buildEditProtocolMessage(target, decrypted, 1754216820000) + if rebuilt == nil { + t.Fatal("expected a rebuilt message, got nil") + } + + protocolMessage := rebuilt.GetProtocolMessage() + if protocolMessage.GetType() != waE2E.ProtocolMessage_MESSAGE_EDIT { + t.Fatalf("expected type MESSAGE_EDIT, got %v", protocolMessage.GetType()) + } + + if protocolMessage.GetKey().GetID() != "ORIGINAL_ID" { + t.Fatalf("expected the target message key to be preserved, got %q", protocolMessage.GetKey().GetID()) + } + + if protocolMessage.GetEditedMessage().GetConversation() != "corrected text" { + t.Fatalf("expected the decrypted text, got %q", protocolMessage.GetEditedMessage().GetConversation()) + } + + if protocolMessage.GetTimestampMS() != 1754216820000 { + t.Fatalf("expected the edit timestamp to be carried over, got %d", protocolMessage.GetTimestampMS()) + } +} + +func TestBuildEditProtocolMessageOmitsUnknownTimestamp(t *testing.T) { + rebuilt := buildEditProtocolMessage( + &waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")}, + &waE2E.Message{Conversation: stringPtr("corrected text")}, + 0, + ) + + if rebuilt.GetProtocolMessage().TimestampMS != nil { + t.Fatal("expected no timestamp to be set when the event carries none") + } +} + +func TestBuildEditProtocolMessageRequiresTargetAndContent(t *testing.T) { + decrypted := &waE2E.Message{Conversation: stringPtr("corrected text")} + + if buildEditProtocolMessage(nil, decrypted, 0) != nil { + t.Fatal("expected nil without a target message key") + } + + if buildEditProtocolMessage(&waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")}, nil, 0) != nil { + t.Fatal("expected nil without decrypted content") + } +} + +// The envelope is typed as "secret encrypted", which carries no text. Rebuilding it as a +// protocolMessage is what makes the pipeline treat it as an edit. +func TestRebuiltEditIsTypedAsEdit(t *testing.T) { + envelope := secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT) + if got := utils.GetMessageType(envelope); got == "edit" { + t.Fatalf("expected the sealed envelope not to be typed as an edit, got %q", got) + } + + rebuilt := buildEditProtocolMessage( + envelope.GetSecretEncryptedMessage().GetTargetMessageKey(), + &waE2E.Message{Conversation: stringPtr("corrected text")}, + 0, + ) + + if got := utils.GetMessageType(rebuilt); got != "edit" { + t.Fatalf("expected the rebuilt message to be typed as %q, got %q", "edit", got) + } +} diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 366f0edb..89a3568f 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1236,6 +1236,10 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { }() } + // Edits arrive sealed in a secretEncryptedMessage envelope. Unwrap before typing the + // message, so it is classified as "edit" and the webhook carries the new text. + mycli.unwrapSecretEncryptedEdit(evt) + parsedMessageType := utils.GetMessageType(evt.Message) if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") { mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Message ignored because it's a unknown protocol message", mycli.userID) From d1107db281bf8bac24e794ff6365e35808b25c12 Mon Sep 17 00:00:00 2001 From: Caio-HD Date: Mon, 3 Aug 2026 13:46:28 -0300 Subject: [PATCH 2/2] refactor(message): return the sealed edit envelope instead of a boolean Address review feedback: secretEncryptedEdit now returns the envelope it matched, so the caller reaches TargetMessageKey from that value instead of looking the envelope up a second time. Also guard the nil message explicitly, matching the style already used by getContextInfoFromMessage in referral.go. --- pkg/whatsmeow/service/secret_edit.go | 27 +++++++++++++++++------ pkg/whatsmeow/service/secret_edit_test.go | 21 +++++++++++------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/pkg/whatsmeow/service/secret_edit.go b/pkg/whatsmeow/service/secret_edit.go index ee157f87..a5259fc4 100644 --- a/pkg/whatsmeow/service/secret_edit.go +++ b/pkg/whatsmeow/service/secret_edit.go @@ -9,15 +9,23 @@ import ( "google.golang.org/protobuf/proto" ) -// isSecretEncryptedEdit reports whether the message is an edit sealed in a -// secretEncryptedMessage envelope. +// secretEncryptedEdit returns the envelope carrying a sealed message edit, or nil when the +// message is not one. // // WhatsApp no longer sends message edits as a plaintext protocolMessage: the new content is // encrypted with a key derived from the target message secret. Without decrypting it, the // envelope reaches consumers as an opaque blob with no text at all. -func isSecretEncryptedEdit(message *waE2E.Message) bool { - enc := message.GetSecretEncryptedMessage() - return enc != nil && enc.GetSecretEncType() == waE2E.SecretEncryptedMessage_MESSAGE_EDIT +func secretEncryptedEdit(message *waE2E.Message) *waE2E.SecretEncryptedMessage { + if message == nil { + return nil + } + + envelope := message.GetSecretEncryptedMessage() + if envelope == nil || envelope.GetSecretEncType() != waE2E.SecretEncryptedMessage_MESSAGE_EDIT { + return nil + } + + return envelope } // buildEditProtocolMessage rebuilds the plaintext shape consumers already handle — @@ -48,7 +56,12 @@ func buildEditProtocolMessage(target *waCommon.MessageKey, decrypted *waE2E.Mess // Every failure path leaves the event untouched on purpose: forwarding the original envelope // is what happens today, while dropping the event would lose the edit signal entirely. func (mycli *MyClient) unwrapSecretEncryptedEdit(evt *events.Message) { - if evt == nil || !isSecretEncryptedEdit(evt.Message) { + if evt == nil { + return + } + + envelope := secretEncryptedEdit(evt.Message) + if envelope == nil { return } @@ -57,7 +70,7 @@ func (mycli *MyClient) unwrapSecretEncryptedEdit(evt *events.Message) { return } - targetKey := evt.Message.GetSecretEncryptedMessage().GetTargetMessageKey() + targetKey := envelope.GetTargetMessageKey() decrypted, err := client.DecryptSecretEncryptedMessage(context.Background(), evt) if err != nil { diff --git a/pkg/whatsmeow/service/secret_edit_test.go b/pkg/whatsmeow/service/secret_edit_test.go index 92007dcc..faa314b8 100644 --- a/pkg/whatsmeow/service/secret_edit_test.go +++ b/pkg/whatsmeow/service/secret_edit_test.go @@ -19,20 +19,25 @@ func secretEditEnvelope(encType waE2E.SecretEncryptedMessage_SecretEncType) *waE } } -func TestIsSecretEncryptedEdit(t *testing.T) { - if !isSecretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT)) { +func TestSecretEncryptedEdit(t *testing.T) { + envelope := secretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT)) + if envelope == nil { t.Fatal("expected MESSAGE_EDIT envelope to be detected as an edit") } - if isSecretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_POLL_EDIT)) { + if envelope.GetTargetMessageKey().GetID() != "ORIGINAL_ID" { + t.Fatalf("expected the target message key to be reachable, got %q", envelope.GetTargetMessageKey().GetID()) + } + + if secretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_POLL_EDIT)) != nil { t.Fatal("expected POLL_EDIT envelope not to be treated as a message edit") } - if isSecretEncryptedEdit(&waE2E.Message{Conversation: stringPtr("plain text")}) { + if secretEncryptedEdit(&waE2E.Message{Conversation: stringPtr("plain text")}) != nil { t.Fatal("expected a plain message not to be treated as an edit") } - if isSecretEncryptedEdit(nil) { + if secretEncryptedEdit(nil) != nil { t.Fatal("expected a nil message not to be treated as an edit") } } @@ -91,13 +96,13 @@ func TestBuildEditProtocolMessageRequiresTargetAndContent(t *testing.T) { // The envelope is typed as "secret encrypted", which carries no text. Rebuilding it as a // protocolMessage is what makes the pipeline treat it as an edit. func TestRebuiltEditIsTypedAsEdit(t *testing.T) { - envelope := secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT) - if got := utils.GetMessageType(envelope); got == "edit" { + sealed := secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT) + if got := utils.GetMessageType(sealed); got == "edit" { t.Fatalf("expected the sealed envelope not to be typed as an edit, got %q", got) } rebuilt := buildEditProtocolMessage( - envelope.GetSecretEncryptedMessage().GetTargetMessageKey(), + secretEncryptedEdit(sealed).GetTargetMessageKey(), &waE2E.Message{Conversation: stringPtr("corrected text")}, 0, )