feat: 接入消息加密与实名认证能力
This commit is contained in:
@@ -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(¤tVersion); 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)
|
||||
}
|
||||
}
|
||||
@@ -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 ©, 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(©, entry.version)))
|
||||
return ©, 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
|
||||
}
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user