diff --git a/server/store/message_crypto.go b/server/store/message_crypto.go index 6992974..6dadf49 100644 --- a/server/store/message_crypto.go +++ b/server/store/message_crypto.go @@ -196,7 +196,9 @@ func encryptMessageContent(msg *types.Message) (*types.Message, error) { } func decryptMessageContent(msg *types.Message) error { - if msg == nil || msg.KeyVersion <= 0 { + // 撤回消息的内容已被清空(recall 会把 head/content 置 NULL,但 content_nonce/key_version 残留), + // 此类行没有密文可解,直接放行让上层按已撤回形态下发,否则整个 get data 窗口会被单条撤回消息毒化成 500。 + if msg == nil || msg.KeyVersion <= 0 || msg.Content == nil { return nil } entry, err := getMessageKey(msg.TenantID, msg.KeyVersion) diff --git a/server/store/message_crypto_test.go b/server/store/message_crypto_test.go index 651e285..35cce28 100644 --- a/server/store/message_crypto_test.go +++ b/server/store/message_crypto_test.go @@ -82,6 +82,27 @@ func TestMessageCryptoRejectsTampering(t *testing.T) { } } +// 撤回消息 content 已被清 NULL,但 recall UPDATE 保留了 content_nonce/key_version; +// 读路径解密必须跳过此类行,否则单条撤回消息会把整个 get data 窗口毒化成 500(2026-10-08 测试环境实案)。 +func TestMessageCryptoSkipsRecalledContent(t *testing.T) { + tenantID := types.TenantID(9004) + key := bytes.Repeat([]byte{5}, 32) + expires := time.Now().Add(time.Minute) + messageKeyCache.Lock() + messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{enabled: true, version: 2, key: key, expires: expires} + messageKeyCache.items[messageKeyCacheID(tenantID, 2)] = cachedMessageKey{enabled: true, version: 2, key: key, expires: expires} + messageKeyCache.Unlock() + defer InvalidateMessageEncryptionKeys(tenantID) + + recalled := &types.Message{TenantID: tenantID, Topic: "p2p-recalled", SeqId: 64, KeyVersion: 2, ContentNonce: bytes.Repeat([]byte{1}, 12), Content: nil} + if err := decryptMessageContent(recalled); err != nil { + t.Fatalf("expected recalled message with nil content to be skipped, got: %v", err) + } + if recalled.Content != nil { + t.Fatalf("nil content should stay nil: %#v", recalled.Content) + } +} + func TestMessageCryptoSearchMatchesDecryptedContent(t *testing.T) { content := map[string]any{"txt": "项目 验收通过", "nested": map[string]any{"title": "日报"}} if !messageContentMatchesSearch(content, "验收") {