feat: 接入消息加密与实名认证能力

This commit is contained in:
richjohnrambo
2026-09-21 09:53:39 +08:00
parent 841c0c77cd
commit e3b93113f1
33 changed files with 1747 additions and 99 deletions
+3
View File
@@ -65,6 +65,7 @@ var (
moderationPolicyCache = newConfigCache[*types.ModerationPolicy]()
moderationWordsCache = newConfigCache[*types.SensitiveWordSet]()
securityPolicyCache = newConfigCache[*types.SecurityPolicy]()
realNamePolicyCache = newConfigCache[*types.RealNamePolicy]()
thirdPartyConfigAllKeys = "*"
)
@@ -145,8 +146,10 @@ func InvalidateTenantConfigCache(code string, id types.TenantID) {
}
if id.IsValid() {
tenantByIDCache.delete(formatTenantIDForCache(id))
realNamePolicyCache.delete(formatTenantIDForCache(id))
} else if strings.TrimSpace(code) == "" {
tenantByIDCache.clear()
realNamePolicyCache.clear()
}
}
@@ -0,0 +1,191 @@
//go:build mysql
package store_test
import (
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/tinode/chat/server/db/mysql"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type localCryptoProvider struct {
key string
}
func (p localCryptoProvider) GetTenantByCode(string) (*types.Tenant, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) GetTenantByID(types.TenantID) (*types.Tenant, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ListActiveTenants(int) ([]types.Tenant, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) RegistrationSMSEnabled() (bool, error) {
return false, types.ErrUnsupported
}
func (p localCryptoProvider) ThirdPartyConfig(string, ...string) (map[string]string, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) LatestAppRelease(string, string) (*types.AppRelease, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ModerationProvider() (*types.ModerationProviderConfig, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ModerationPolicy(string) (*types.ModerationPolicy, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) SensitiveWords(string) (*types.SensitiveWordSet, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) SecurityPolicy(string) (*types.SecurityPolicy, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) RealNamePolicy(types.TenantID) (*types.RealNamePolicy, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) MessageKey(tenantCode string, version int) (*types.MessageKey, error) {
if tenantCode != "default" || (version != 0 && version != 7) {
return nil, types.ErrNotFound
}
return &types.MessageKey{TenantID: 1, TenantCode: tenantCode, Enabled: true, Version: 7, Algorithm: "AES-256-GCM", Status: "active", Key: p.key}, nil
}
func (p localCryptoProvider) MessageKeyAck(string, int) error { return nil }
func TestLocalMySQLMessageCryptoFlow(t *testing.T) {
if os.Getenv("IM_LOCAL_CRYPTO_TEST") != "1" {
t.Skip("set IM_LOCAL_CRYPTO_TEST=1 to run against local MySQL")
}
config := json.RawMessage(`{
"uid_key":"dkXxWLwqUNRfYmdXRzOkZw==",
"max_results":1024,
"use_adapter":"mysql",
"adapters":{"mysql":{
"dsn":"mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db":"hotgo",
"tenant_db_routing":true,
"sql_timeout":10
}}
}`)
if err := store.Store.Open(991, config); err != nil {
t.Fatal(err)
}
defer store.Store.Close()
defer store.SetAdminInternalConfigProvider(nil)
store.SetAdminInternalConfigProvider(localCryptoProvider{key: base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))})
store.InvalidateMessageEncryptionKeys(1)
db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci")
if err != nil {
t.Fatal(err)
}
defer db.Close()
var topic string
var senderID, originalTopicSeq int64
err = db.QueryRow(`SELECT s.topic,s.userid,t.seqid FROM im_subscriptions s JOIN im_topics t ON t.name=s.topic
WHERE s.topic LIKE 'p2p%' AND s.deletedat IS NULL ORDER BY t.id DESC LIMIT 1`).Scan(&topic, &senderID, &originalTopicSeq)
if err != nil {
t.Fatal(err)
}
sender := store.EncodeUid(senderID)
marker := fmt.Sprintf("LOCAL-CRYPTO-%d", time.Now().UnixNano())
msg := &types.Message{
TenantID: 1,
SeqId: originalTopicSeq + 1,
Topic: topic,
From: sender.String(),
Head: types.KVMap{"x-im-client-mid": marker, "x-im-type": "text"},
Content: map[string]any{"txt": marker},
}
msg.InitTimes()
msg.SetUid(store.Store.GetUid())
fallbackSeq := int64(0)
defer func() {
_, _ = db.Exec("DELETE FROM im_message_revisions WHERE topic=? AND seqid=?", topic, msg.SeqId)
_, _ = db.Exec("DELETE FROM im_messages WHERE topic=? AND seqid=?", topic, msg.SeqId)
if fallbackSeq > 0 {
_, _ = db.Exec("DELETE FROM im_messages WHERE topic=? AND seqid=?", topic, fallbackSeq)
}
_, _ = db.Exec("UPDATE im_topics SET seqid=? WHERE name=? AND seqid IN (?,?)", originalTopicSeq, topic, msg.SeqId, fallbackSeq)
}()
if _, err = store.PersistPreparedMessage(1, msg, nil, false); err != nil {
t.Fatal(err)
}
var rawContent string
var nonce []byte
var keyVersion int
if err = db.QueryRow("SELECT JSON_UNQUOTE(content),content_nonce,key_version FROM im_messages WHERE topic=? AND seqid=?", topic, msg.SeqId).Scan(&rawContent, &nonce, &keyVersion); err != nil {
t.Fatal(err)
}
if keyVersion != 7 || len(nonce) != 12 || strings.Contains(rawContent, marker) {
t.Fatalf("message not encrypted at rest: key_version=%d nonce=%d content=%q", keyVersion, len(nonce), rawContent)
}
if _, err = base64.StdEncoding.DecodeString(rawContent); err != nil {
t.Fatalf("stored ciphertext is not base64: %v", err)
}
loaded, err := store.Messages.GetOne(1, topic, msg.SeqId)
if err != nil || fmt.Sprint(loaded.Content.(map[string]any)["txt"]) != marker {
t.Fatalf("GetOne plaintext mismatch: content=%#v err=%v", loaded, err)
}
editedMarker := marker + "-EDITED"
edited, err := store.Messages.Edit(1, topic, msg.SeqId, sender, loaded.Version, loaded.Head, map[string]any{"txt": editedMarker}, "local integration test", types.TimeNow())
if err != nil || fmt.Sprint(edited.Content.(map[string]any)["txt"]) != editedMarker {
t.Fatalf("Edit plaintext mismatch: content=%#v err=%v", edited, err)
}
var revisionVersion, currentVersion int
if err = db.QueryRow("SELECT key_version FROM im_message_revisions WHERE topic=? AND seqid=? ORDER BY version DESC LIMIT 1", topic, msg.SeqId).Scan(&revisionVersion); err != nil {
t.Fatal(err)
}
if err = db.QueryRow("SELECT key_version FROM im_messages WHERE topic=? AND seqid=?", topic, msg.SeqId).Scan(&currentVersion); err != nil {
t.Fatal(err)
}
if revisionVersion != 7 || currentVersion != 7 {
t.Fatalf("unexpected key versions: revision=%d current=%d", revisionVersion, currentVersion)
}
history, err := store.Messages.GetAll(1, topic, sender, &types.QueryOpt{Since: msg.SeqId, Before: msg.SeqId + 1, Limit: 10})
if err != nil || len(history) != 1 || fmt.Sprint(history[0].Content.(map[string]any)["txt"]) != editedMarker {
t.Fatalf("history plaintext mismatch: messages=%#v err=%v", history, err)
}
found, _, err := store.Messages.Search(1, topic, sender, editedMarker, "text", "", 10)
if err != nil || len(found) != 1 || found[0].SeqId != msg.SeqId {
t.Fatalf("search plaintext mismatch: messages=%#v err=%v", found, err)
}
store.SetAdminInternalConfigProvider(localCryptoProvider{})
store.InvalidateMessageEncryptionKeys(1)
fallback := &types.Message{
TenantID: 1,
SeqId: msg.SeqId + 1,
Topic: topic,
From: sender.String(),
Head: types.KVMap{"x-im-client-mid": marker + "-FALLBACK", "x-im-type": "text"},
Content: map[string]any{"txt": marker + "-PLAINTEXT"},
}
fallback.InitTimes()
fallback.SetUid(store.Store.GetUid())
fallbackSeq = fallback.SeqId
if _, err = store.PersistPreparedMessage(1, fallback, nil, false); err != nil {
t.Fatal(err)
}
if err = db.QueryRow("SELECT JSON_UNQUOTE(content),key_version FROM im_messages WHERE topic=? AND seqid=?", topic, fallback.SeqId).Scan(&rawContent, &keyVersion); err != nil {
t.Fatal(err)
}
if keyVersion != 0 || !strings.Contains(rawContent, marker+"-PLAINTEXT") {
t.Fatalf("missing-key fallback was not plaintext: key_version=%d content=%q", keyVersion, rawContent)
}
}
+260
View File
@@ -0,0 +1,260 @@
package store
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store/types"
)
type cachedMessageKey struct {
enabled bool
version int
status string
key []byte
expires time.Time
}
const messageKeyCacheTTL = 5 * time.Minute
var messageKeyCache = struct {
sync.RWMutex
items map[string]cachedMessageKey
}{items: make(map[string]cachedMessageKey)}
func messageKeyCacheID(tenantID types.TenantID, version int) string {
return strconv.FormatInt(int64(tenantID), 10) + ":" + strconv.Itoa(version)
}
// WarmMessageEncryptionKeys preloads active keys. Missing keys leave the tenant in plaintext mode.
func WarmMessageEncryptionKeys() {
if adminInternalConfigProvider == nil || Tenants == nil {
return
}
tenants, err := Tenants.ListActive(10000)
if err != nil {
logs.Warn.Printf("message crypto: tenant preload failed: %v", err)
return
}
for i := range tenants {
if _, err = loadMessageKey(&tenants[i], 0); err != nil {
logs.Info.Printf("message crypto: plaintext mode tenant=%s reason=%v", tenants[i].Code, err)
}
}
}
func loadMessageKey(tenant *types.Tenant, version int) (cachedMessageKey, error) {
var zero cachedMessageKey
if tenant == nil || adminInternalConfigProvider == nil {
return zero, types.ErrNotFound
}
result, err := adminInternalConfigProvider.MessageKey(tenant.Code, version)
if err != nil || result == nil {
if err == nil {
err = types.ErrNotFound
}
return zero, err
}
decoded, err := base64.StdEncoding.DecodeString(result.Key)
if err != nil || len(decoded) != 32 {
return zero, errors.New("invalid tenant message key")
}
entry := cachedMessageKey{enabled: result.Enabled, version: result.Version, status: result.Status, key: decoded, expires: time.Now().Add(messageKeyCacheTTL)}
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenant.ID, result.Version)] = entry
if version == 0 {
messageKeyCache.items[messageKeyCacheID(tenant.ID, 0)] = entry
}
messageKeyCache.Unlock()
return entry, nil
}
func getMessageKey(tenantID types.TenantID, version int) (cachedMessageKey, error) {
cacheID := messageKeyCacheID(tenantID, version)
messageKeyCache.RLock()
entry, ok := messageKeyCache.items[cacheID]
messageKeyCache.RUnlock()
if ok && time.Now().Before(entry.expires) {
return entry, nil
}
if ok {
messageKeyCache.Lock()
delete(messageKeyCache.items, cacheID)
messageKeyCache.Unlock()
}
tenant, err := Tenants.GetByID(tenantID)
if err != nil {
return cachedMessageKey{}, err
}
entry, err = loadMessageKey(tenant, version)
if err != nil && version == 0 {
entry = cachedMessageKey{expires: time.Now().Add(messageKeyCacheTTL)}
messageKeyCache.Lock()
messageKeyCache.items[cacheID] = entry
messageKeyCache.Unlock()
return entry, nil
}
return entry, err
}
// PrepareMessageEncryptionKey verifies a pending key in memory and acknowledges
// it to Admin Server. Admin activates the key only after this succeeds.
func PrepareMessageEncryptionKey(tenantID types.TenantID, tenantCode string, version int) error {
if version <= 0 || adminInternalConfigProvider == nil {
return types.ErrMalformed
}
var tenant *types.Tenant
var err error
if tenantID.IsValid() {
tenant, err = Tenants.GetByID(tenantID)
} else {
tenant, err = Tenants.GetByCode(strings.TrimSpace(tenantCode))
}
if err != nil || tenant == nil {
return err
}
entry, err := loadMessageKey(tenant, version)
if err != nil {
return err
}
if strings.EqualFold(entry.status, "pending") {
if err = adminInternalConfigProvider.MessageKeyAck(tenant.Code, version); err != nil {
return err
}
}
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenant.ID, 0)] = entry
messageKeyCache.Unlock()
return nil
}
// InvalidateMessageEncryptionKeys forces the next access to reload tenant keys.
func InvalidateMessageEncryptionKeys(tenantID types.TenantID) {
prefix := strconv.FormatInt(int64(tenantID), 10) + ":"
messageKeyCache.Lock()
for key := range messageKeyCache.items {
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
delete(messageKeyCache.items, key)
}
}
messageKeyCache.Unlock()
}
func messageAAD(msg *types.Message, keyVersion int) []byte {
topic := []byte(msg.Topic)
aad := make([]byte, 8+8+4+len(topic))
binary.BigEndian.PutUint64(aad[0:8], uint64(msg.TenantID))
binary.BigEndian.PutUint64(aad[8:16], uint64(msg.SeqId))
binary.BigEndian.PutUint32(aad[16:20], uint32(keyVersion))
copy(aad[20:], topic)
return aad
}
func encryptMessageContent(msg *types.Message) (*types.Message, error) {
entry, err := getMessageKey(msg.TenantID, 0)
if err != nil || !entry.enabled || entry.version <= 0 {
if err != nil {
logs.Warn.Printf("message crypto: active key unavailable, storing plaintext tenant=%d reason=%v", msg.TenantID, err)
}
copy := *msg
copy.KeyVersion, copy.ContentNonce = 0, nil
return &copy, nil
}
plain, err := json.Marshal(msg.Content)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(entry.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
copy := *msg
copy.KeyVersion = entry.version
copy.ContentNonce = nonce
copy.Content = base64.StdEncoding.EncodeToString(gcm.Seal(nil, nonce, plain, messageAAD(&copy, entry.version)))
return &copy, nil
}
func decryptMessageContent(msg *types.Message) error {
if msg == nil || msg.KeyVersion <= 0 {
return nil
}
entry, err := getMessageKey(msg.TenantID, msg.KeyVersion)
if err != nil {
return fmt.Errorf("message key version %d unavailable: %w", msg.KeyVersion, err)
}
ciphertextText, ok := msg.Content.(string)
if !ok {
return errors.New("encrypted message content is not a string")
}
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextText)
if err != nil {
return err
}
block, err := aes.NewCipher(entry.key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
if len(msg.ContentNonce) != gcm.NonceSize() {
return fmt.Errorf("invalid message nonce length: got %d want %d", len(msg.ContentNonce), gcm.NonceSize())
}
plain, err := gcm.Open(nil, msg.ContentNonce, ciphertext, messageAAD(msg, msg.KeyVersion))
if err != nil {
return err
}
return json.Unmarshal(plain, &msg.Content)
}
func decryptMessages(messages []types.Message) error {
for i := range messages {
if err := decryptMessageContent(&messages[i]); err != nil {
return err
}
}
return nil
}
func messageContentMatchesSearch(content any, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return true
}
raw, err := json.Marshal(content)
if err != nil {
return false
}
text := strings.ToLower(string(raw))
if strings.Contains(text, query) {
return true
}
for _, term := range strings.Fields(query) {
if !strings.Contains(text, term) {
return false
}
}
return true
}
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"bytes"
"testing"
"time"
"github.com/tinode/chat/server/store/types"
)
func TestMessageCryptoRoundTrip(t *testing.T) {
tenantID := types.TenantID(9001)
key := bytes.Repeat([]byte{7}, 32)
messageKeyCache.Lock()
expires := time.Now().Add(time.Minute)
messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{enabled: true, version: 3, key: key, expires: expires}
messageKeyCache.items[messageKeyCacheID(tenantID, 3)] = cachedMessageKey{enabled: true, version: 3, key: key, expires: expires}
messageKeyCache.Unlock()
defer InvalidateMessageEncryptionKeys(tenantID)
original := &types.Message{TenantID: tenantID, Topic: "p2p-test", SeqId: 42, Content: map[string]any{"txt": "hello"}}
stored, err := encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
if stored.KeyVersion != 3 || len(stored.ContentNonce) != 12 || stored.Content == original.Content {
t.Fatalf("message was not encrypted: %#v", stored)
}
if err = decryptMessageContent(stored); err != nil {
t.Fatal(err)
}
content, ok := stored.Content.(map[string]any)
if !ok || content["txt"] != "hello" {
t.Fatalf("unexpected plaintext: %#v", stored.Content)
}
}
func TestMessageCryptoPlaintextWhenDisabled(t *testing.T) {
tenantID := types.TenantID(9002)
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{expires: time.Now().Add(time.Minute)}
messageKeyCache.Unlock()
defer InvalidateMessageEncryptionKeys(tenantID)
original := &types.Message{TenantID: tenantID, Topic: "grp-test", SeqId: 7, Content: "plain"}
stored, err := encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
if stored.KeyVersion != 0 || stored.Content != "plain" || len(stored.ContentNonce) != 0 {
t.Fatalf("unexpected encrypted message: %#v", stored)
}
}
func TestMessageCryptoRejectsTampering(t *testing.T) {
tenantID := types.TenantID(9003)
key := bytes.Repeat([]byte{9}, 32)
expires := time.Now().Add(time.Minute)
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{enabled: true, version: 4, key: key, expires: expires}
messageKeyCache.items[messageKeyCacheID(tenantID, 4)] = cachedMessageKey{enabled: true, version: 4, key: key, expires: expires}
messageKeyCache.Unlock()
defer InvalidateMessageEncryptionKeys(tenantID)
original := &types.Message{TenantID: tenantID, Topic: "p2p-secure", SeqId: 8, Content: map[string]any{"txt": "secret"}}
stored, err := encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
stored.Topic = "p2p-tampered"
if err = decryptMessageContent(stored); err == nil {
t.Fatal("expected authenticated decryption to reject changed topic")
}
stored, err = encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
stored.ContentNonce = stored.ContentNonce[:len(stored.ContentNonce)-1]
if err = decryptMessageContent(stored); err == nil {
t.Fatal("expected invalid nonce length to be rejected")
}
}
func TestMessageCryptoSearchMatchesDecryptedContent(t *testing.T) {
content := map[string]any{"txt": "项目 验收通过", "nested": map[string]any{"title": "日报"}}
if !messageContentMatchesSearch(content, "验收") {
t.Fatal("expected plaintext keyword to match")
}
if messageContentMatchesSearch(content, "不存在") {
t.Fatal("unexpected keyword match")
}
}
+159 -11
View File
@@ -174,6 +174,9 @@ type AdminInternalConfigProvider interface {
ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error)
SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error)
SecurityPolicy(tenantCode string) (*types.SecurityPolicy, error)
RealNamePolicy(tenantID types.TenantID) (*types.RealNamePolicy, error)
MessageKey(tenantCode string, version int) (*types.MessageKey, error)
MessageKeyAck(tenantCode string, version int) error
}
var adminInternalConfigProvider AdminInternalConfigProvider
@@ -319,6 +322,61 @@ func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]stri
return result, err
}
type RealNameConfigPersistenceInterface interface {
Policy(tenantID types.TenantID) (*types.RealNamePolicy, error)
}
type realNameConfigMapper struct{}
var RealNameConfig RealNameConfigPersistenceInterface = realNameConfigMapper{}
func (realNameConfigMapper) Policy(tenantID types.TenantID) (*types.RealNamePolicy, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
cacheKey := formatTenantIDForCache(tenantID)
if cached, ok := realNamePolicyCache.get(cacheKey); ok {
cp := *cached
return &cp, nil
}
if adminInternalConfigProvider != nil {
if result, err := adminInternalConfigProvider.RealNamePolicy(tenantID); err == nil && result != nil {
result.Normalize()
realNamePolicyCache.set(cacheKey, result)
cp := *result
return &cp, nil
} else if err != nil {
logs.Warn.Printf("admin internal realname policy fallback failed: tenant=%d err=%v", tenantID, err)
}
}
configAdp, ok := adp.(adapter.RealNamePolicyConfigAdapter)
if !ok {
policy := defaultRealNamePolicy(tenantID)
realNamePolicyCache.set(cacheKey, policy)
return policy, nil
}
result, err := configAdp.RealNamePolicy(tenantID)
if err != nil {
return nil, err
}
result.Normalize()
realNamePolicyCache.set(cacheKey, result)
cp := *result
return &cp, nil
}
func defaultRealNamePolicy(tenantID types.TenantID) *types.RealNamePolicy {
policy := &types.RealNamePolicy{
TenantID: tenantID,
ReviewPolicy: types.RealNameReviewPolicyNone,
ProviderMode: "id_card_2factor",
AllowSkip: true,
RemindAfter: "24h",
}
policy.Normalize()
return policy
}
type TenantEntryPersistenceInterface interface {
CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error)
CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error)
@@ -1490,10 +1548,16 @@ func (messagesMapper) GetOne(tenantID types.TenantID, topic string, seq int64) (
if err != nil {
return nil, err
}
var msg *types.Message
if types.GetTopicCat(topic) == types.TopicCatGrp {
return tenantAdp.TenantGroupMessageGetOne(tenantID, topic, seq)
msg, err = tenantAdp.TenantGroupMessageGetOne(tenantID, topic, seq)
} else {
msg, err = tenantAdp.TenantMessageGetOne(tenantID, topic, seq)
}
return tenantAdp.TenantMessageGetOne(tenantID, topic, seq)
if err == nil {
err = decryptMessageContent(msg)
}
return msg, err
}
func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
@@ -1503,6 +1567,9 @@ func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int64, a
}
if types.GetTopicCat(topic) == types.TopicCatGrp {
msg, err := tenantAdp.TenantGroupMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt)
if err == nil {
err = decryptMessageContent(msg)
}
if err == nil && groupMsgRuntime != nil {
if cacheErr := groupMsgRuntime.CacheHotMessage(tenantID, msg); cacheErr != nil {
logs.Warn.Printf("topic[%s]: failed to refresh recalled group message cache: %v", topic, cacheErr)
@@ -1510,7 +1577,11 @@ func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int64, a
}
return msg, err
}
return tenantAdp.TenantMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt)
msg, err := tenantAdp.TenantMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt)
if err == nil {
err = decryptMessageContent(msg)
}
return msg, err
}
func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
@@ -1521,8 +1592,16 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int64, act
if err != nil {
return nil, err
}
stored, err := encryptMessageContent(&types.Message{TenantID: tenantID, Topic: topic, SeqId: seq, Content: content})
if err != nil {
return nil, err
}
storedContent := types.StoredMessageContent{Content: stored.Content, Nonce: stored.ContentNonce, KeyVersion: stored.KeyVersion}
if types.GetTopicCat(topic) == types.TopicCatGrp {
msg, err := tenantAdp.TenantGroupMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
msg, err := tenantAdp.TenantGroupMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, storedContent, reason, editedAt)
if err == nil {
err = decryptMessageContent(msg)
}
if err == nil && groupMsgRuntime != nil {
if cacheErr := groupMsgRuntime.CacheHotMessage(tenantID, msg); cacheErr != nil {
logs.Warn.Printf("topic[%s]: failed to refresh edited group message cache: %v", topic, cacheErr)
@@ -1530,7 +1609,11 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int64, act
}
return msg, err
}
return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
msg, err := tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, storedContent, reason, editedAt)
if err == nil {
err = decryptMessageContent(msg)
}
return msg, err
}
type ConversationsPersistenceInterface interface {
@@ -3237,10 +3320,14 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
msg.ClientMsgID = clientMsgID
fromUid := types.ParseUid(msg.From)
isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp
storedMsg, err := encryptMessageContent(msg)
if err != nil {
return nil, err
}
if isGroupMessage {
err = tenantAdp.TenantGroupMessageSave(tenantID, msg)
err = tenantAdp.TenantGroupMessageSave(tenantID, storedMsg)
} else {
err = tenantAdp.TenantMessageSave(tenantID, msg)
err = tenantAdp.TenantMessageSave(tenantID, storedMsg)
}
if err != nil {
if err == types.ErrDuplicate {
@@ -3250,6 +3337,9 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return nil, dupErr
}
if duplicate != nil {
if dupErr = decryptMessageContent(duplicate); dupErr != nil {
return nil, dupErr
}
msg.SeqId = duplicate.SeqId
msg.ShardMonth = duplicate.ShardMonth
if groupMsgRuntime != nil {
@@ -3264,11 +3354,14 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return nil, dupErr
}
if duplicate != nil {
if dupErr = decryptMessageContent(duplicate); dupErr != nil {
return nil, dupErr
}
copyPersistedMessage(msg, duplicate)
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
}
if retryErr := retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg, isGroupMessage); retryErr != nil {
if retryErr := retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, storedMsg, isGroupMessage); retryErr != nil {
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
_ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
@@ -3282,6 +3375,7 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return nil, err
}
}
msg.SetUid(storedMsg.Uid())
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return nil, err
}
@@ -3363,6 +3457,8 @@ func copyPersistedMessage(dst, src *types.Message) {
dst.ClientMsgID = src.ClientMsgID
dst.Head = src.Head
dst.Content = src.Content
dst.ContentNonce = src.ContentNonce
dst.KeyVersion = src.KeyVersion
}
func PrepareMessagePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
@@ -3392,7 +3488,17 @@ func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdap
return types.ErrDuplicate
}
prevSeq := msg.SeqId
plainMsg := *msg
if err = decryptMessageContent(&plainMsg); err != nil {
return err
}
msg.SeqId = latest[0].SeqId + 1
plainMsg.SeqId = msg.SeqId
if encryptedMsg, encryptErr := encryptMessageContent(&plainMsg); encryptErr != nil {
return encryptErr
} else {
*msg = *encryptedMsg
}
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return err
}
@@ -3647,9 +3753,17 @@ func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser type
return msgs, nil
}
}
return tenantAdp.TenantGroupMessageGetAll(tenantID, topic, forUser, opt)
messages, err := tenantAdp.TenantGroupMessageGetAll(tenantID, topic, forUser, opt)
if err == nil {
err = decryptMessages(messages)
}
return messages, err
}
return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
messages, err := tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
if err == nil {
err = decryptMessages(messages)
}
return messages, err
}
func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) {
@@ -3663,7 +3777,40 @@ func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser type
if err != nil {
return nil, "", err
}
return tenantAdp.TenantMessageSearch(tenantID, topic, forUser, query, messageType, cursor, limit)
query = strings.TrimSpace(query)
if query == "" {
messages, nextCursor, searchErr := tenantAdp.TenantMessageSearch(tenantID, topic, forUser, "", messageType, cursor, limit)
if searchErr == nil {
searchErr = decryptMessages(messages)
}
return messages, nextCursor, searchErr
}
// Encrypted content cannot be filtered by SQL. Walk visible messages in
// sequence order, decrypt them, then apply the same user-facing search.
matches := make([]types.Message, 0, limit)
nextCursor := cursor
for {
batch, batchCursor, searchErr := tenantAdp.TenantMessageSearch(tenantID, topic, forUser, "", messageType, nextCursor, 100)
if searchErr != nil {
return nil, "", searchErr
}
if searchErr = decryptMessages(batch); searchErr != nil {
return nil, "", searchErr
}
for _, msg := range batch {
if messageContentMatchesSearch(msg.Content, query) {
matches = append(matches, msg)
if len(matches) == limit {
return matches, strconv.FormatInt(msg.SeqId, 10), nil
}
}
}
if batchCursor == "" || len(batch) == 0 {
return matches, "", nil
}
nextCursor = batchCursor
}
}
func (messagesMapper) Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error) {
@@ -4661,6 +4808,7 @@ func init() {
Store = storeObj{}
Tenants = tenantsMapper{}
RegistrationConfig = registrationConfigMapper{}
RealNameConfig = realNameConfigMapper{}
TenantEntry = tenantEntryMapper{}
InternalAdminQueries = internalAdminQueryMapper{}
SecurityAudit = securityAuditMapper{}
+19
View File
@@ -0,0 +1,19 @@
package types
// MessageKey is a tenant message data-encryption key returned by Admin Server.
type MessageKey struct {
TenantID TenantID `json:"tenantId"`
TenantCode string `json:"tenantCode"`
Enabled bool `json:"enabled"`
Version int `json:"version"`
Algorithm string `json:"algorithm"`
Status string `json:"status"`
Key string `json:"key"`
}
// StoredMessageContent carries encrypted content metadata through the edit adapter boundary.
type StoredMessageContent struct {
Content any
Nonce []byte
KeyVersion int
}
+28
View File
@@ -2,6 +2,34 @@ package types
import "time"
const (
RealNameReviewPolicyNone = iota
RealNameReviewPolicyAutoManual
)
// RealNamePolicy controls tenant-level real-name review behavior.
type RealNamePolicy struct {
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
ReviewPolicy int `db:"realname_review_policy" json:"realname_review_policy"`
ProviderMode string `db:"realname_provider_mode" json:"realname_provider_mode"`
Required bool `db:"realname_required" json:"realname_required"`
AllowSkip bool `db:"realname_allow_skip" json:"realname_allow_skip"`
RemindAfter string `db:"realname_remind_after" json:"realname_remind_after"`
}
// Normalize fills defaults and clamps unsupported enum values.
func (p *RealNamePolicy) Normalize() {
if p.ProviderMode == "" {
p.ProviderMode = "id_card_2factor"
}
if p.RemindAfter == "" {
p.RemindAfter = "24h"
}
if p.ReviewPolicy != RealNameReviewPolicyAutoManual {
p.ReviewPolicy = RealNameReviewPolicyNone
}
}
// RealNameVerification is the latest real-name verification submission for a user.
type RealNameVerification struct {
ID int64 `db:"id" json:"-"`
+20 -16
View File
@@ -1250,26 +1250,30 @@ type Message struct {
Topic string
ShardMonth string `db:"shard_month" json:"-" bson:"shard_month,omitempty"`
// Sender's user ID as string (without 'usr' prefix), could be empty.
From string
ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"`
Version int `json:"Version,omitempty" bson:",omitempty"`
RecalledAt *time.Time `db:"recalled_at" json:"RecalledAt,omitempty" bson:"recalled_at,omitempty"`
RecalledBy string `json:"RecalledBy,omitempty" bson:"recalled_by,omitempty"`
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
From string
ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"`
Version int `json:"Version,omitempty" bson:",omitempty"`
RecalledAt *time.Time `db:"recalled_at" json:"RecalledAt,omitempty" bson:"recalled_at,omitempty"`
RecalledBy string `json:"RecalledBy,omitempty" bson:"recalled_by,omitempty"`
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
ContentNonce []byte `db:"content_nonce" json:"-" bson:"content_nonce,omitempty"`
KeyVersion int `db:"key_version" json:"-" bson:"key_version,omitempty"`
}
// MessageRevision stores an immutable snapshot of a message before editing.
type MessageRevision struct {
ObjHeader `bson:",inline"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
Topic string
SeqId int64
Version int
Actor string
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
Reason string
ObjHeader `bson:",inline"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
Topic string
SeqId int64
Version int
Actor string
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
ContentNonce []byte `db:"content_nonce" json:"-" bson:"content_nonce,omitempty"`
KeyVersion int `db:"key_version" json:"-" bson:"key_version,omitempty"`
Reason string
}
// MessageMutation describes an in-place message change used for offline compensation.