owpengram-server/internal/app/secretchat/service.go
Astra 206bde18e0 channels: give kicked/banned/promoted/transferred users a real qts so their client applies it
updateChannelParticipant carries the account's qts per the MTProto spec, but
the server always sent Qts: 0, so real clients silently discarded it as a
stale duplicate -- the banned/kicked user's channel never vanished locally
and no correct "removed by admin" message showed, even though the update was
delivered successfully at the transport layer.

Add a durable per-device qts queue (channel_participant_event_queue) sharing
its qts number space with the existing secret-chat queue (one qts sequence
per device, per spec), and use it to stamp a correct, monotonically
increasing qts on the update for every device of the affected user -- for
channel bans/kicks, admin promotion/demotion, and ownership transfer. A
device offline when it happened can now recover the event via
updates.getDifference instead of missing it permanently.
2026-09-15 15:43:52 +01:00

354 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package secretchat
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 实现密聊握手状态机 + qts 消息投递。所有返回的 domain.SecretChat 都是当时快照。
// 访问校验self/bot/拉黑/隐私)在 rpc 层先行;本层做 DH 校验、chat_id wire 不变量、access_hash 分配、
// 状态机迁移与 qts 队列写入。绑定维度是设备级 perm auth_keyint64
type Service struct {
store store.SecretChatStore
queue store.EncryptedQueueStore
// channelParticipants is the per-device qts queue for channel-membership
// self-notifications (kick/ban/promote/transfer). It shares its qts number
// space with queue (one qts sequence per device, per the MTProto spec for
// updateChannelParticipant) but is otherwise unrelated to secret chats;
// it lives here only because this Service already owns the device qts
// watermark plumbing.
channelParticipants store.ChannelParticipantQueueStore
}
// NewService 创建密聊服务。channelParticipants may be nil in tests that don't
// exercise channel-membership self-notifications.
func NewService(st store.SecretChatStore, queue store.EncryptedQueueStore, channelParticipants store.ChannelParticipantQueueStore) *Service {
return &Service{store: st, queue: queue, channelParticipants: channelParticipants}
}
// RequestEncryption 受理 requestEncryption校验 g_a → 校验 random_id/chat_id 全局唯一性 → 分配双
// access_hash → 盲存 g_a → 落 requested 态。返回的密聊由 rpc 层投影为 admin 视角
// encryptedChatWaiting同步响应与 participant 视角 encryptedChatRequested推送
func (s *Service) RequestEncryption(ctx context.Context, req domain.SecretChatRequest) (domain.SecretChat, error) {
if req.AdminUserID == 0 || req.ParticipantUserID == 0 || req.AdminAuthKeyID == 0 {
return domain.SecretChat{}, ErrGAInvalid
}
if req.RandomID == 0 {
return domain.SecretChat{}, domain.ErrSecretChatRandomIDDuplicate
}
ga, err := validateDHParam(req.GA)
if err != nil {
return domain.SecretChat{}, err
}
// Telegram wire 契约requestEncryption.random_id 同时就是 chat_id。TDLib 会先以
// random_id 创建本地 SecretChatActor并在消费响应时强校验 response.id 相等;禁止
// 用服务端序列替换。全局主键碰撞只允许相同意图的网络重放,其余显式 duplicate。
chatID := int(req.RandomID)
if existing, ok, err := s.store.GetSecretChat(ctx, chatID); err != nil {
return domain.SecretChat{}, err
} else if ok {
if sameSecretChatRequest(existing, req, ga) && !existing.Terminal() {
return existing, nil
}
return domain.SecretChat{}, domain.ErrSecretChatRandomIDDuplicate
}
adminAH, err := randomAccessHash()
if err != nil {
return domain.SecretChat{}, err
}
participantAH, err := randomAccessHash()
if err != nil {
return domain.SecretChat{}, err
}
chat := domain.SecretChat{
ID: chatID,
AdminAccessHash: adminAH,
ParticipantAccessHash: participantAH,
AdminUserID: req.AdminUserID,
ParticipantUserID: req.ParticipantUserID,
AdminAuthKeyID: req.AdminAuthKeyID,
State: domain.SecretChatStateRequested,
GA: ga,
RandomID: req.RandomID,
Date: req.Date,
}
if err := s.store.CreateSecretChat(ctx, chat); err == nil {
return chat, nil
} else if !errors.Is(err, domain.ErrSecretChatRandomIDDuplicate) {
return domain.SecretChat{}, err
}
// 并发相同请求可能在预查后由另一 goroutine 插入;只在重新读取后仍证明
// 是完全相同意图时收敛为幂等成功。
existing, ok, getErr := s.store.GetSecretChat(ctx, chatID)
if getErr != nil {
return domain.SecretChat{}, getErr
}
if ok && sameSecretChatRequest(existing, req, ga) && !existing.Terminal() {
return existing, nil
}
return domain.SecretChat{}, domain.ErrSecretChatRandomIDDuplicate
}
func sameSecretChatRequest(chat domain.SecretChat, req domain.SecretChatRequest, normalizedGA []byte) bool {
return chat.ID == int(req.RandomID) && chat.RandomID == req.RandomID &&
chat.AdminUserID == req.AdminUserID && chat.AdminAuthKeyID == req.AdminAuthKeyID &&
chat.ParticipantUserID == req.ParticipantUserID && bytes.Equal(chat.GA, normalizedGA)
}
// AcceptEncryption 受理 acceptEncryption定位 + participant 视角 access_hash 校验 →
// 校验 g_b → 原子 CAS 绑定接受设备并落 g_b/key_fingerprint → normal。返回的密聊由
// rpc 层投影为 participant 视角 encryptedChatGAOrB=g_a同步响应与 admin 视角
// encryptedChatGAOrB=g_b推送
func (s *Service) AcceptEncryption(ctx context.Context, chatID int, viewerUserID, participantAuthKeyID, accessHash int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) {
gbPadded, err := validateDHParam(gb)
if err != nil {
return domain.SecretChat{}, err
}
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
if err != nil {
return domain.SecretChat{}, err
}
// 视角校验:调用方必须是接受方本人且 access_hash 匹配 participant 视角。
if !ok || chat.ParticipantUserID != viewerUserID || chat.ParticipantAccessHash != accessHash {
return domain.SecretChat{}, domain.ErrSecretChatNotFound
}
switch chat.State {
case domain.SecretChatStateNormal:
return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted
case domain.SecretChatStateDiscarded:
return domain.SecretChat{}, domain.ErrSecretChatAlreadyDeclined
}
return s.store.AcceptSecretChat(ctx, chatID, participantAuthKeyID, gbPadded, keyFingerprint)
}
// DiscardEncryption 受理 discardEncryption定位 + 参与者/绑定设备校验 → 迁移到 discarded。
// already=true 表示已是终态(幂等成功)。返回的密聊由 rpc 层投影为对端
// encryptedChatDiscarded 推送。
func (s *Service) DiscardEncryption(ctx context.Context, chatID int, viewerUserID, viewerAuthKeyID int64, deleteHistory bool) (domain.SecretChat, bool, error) {
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
if err != nil {
return domain.SecretChat{}, false, err
}
if !ok || !chat.HasParticipant(viewerUserID) || viewerAuthKeyID == 0 {
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
}
// Admin 从 request 起即绑定participant 在 accept 前尚无绑定,任一收到账号级邀请的
// participant 设备都可拒绝。accept 一旦完成,双方所有操作都必须来自各自绑定设备。
boundAuthKeyID := chat.AuthKeyOf(viewerUserID)
if boundAuthKeyID != 0 && boundAuthKeyID != viewerAuthKeyID {
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
}
if boundAuthKeyID == 0 && viewerUserID != chat.ParticipantUserID {
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
}
return s.store.DiscardSecretChat(ctx, chatID, deleteHistory)
}
// GetSecretChat 取密聊快照rpc 层访问校验用)。
func (s *Service) GetSecretChat(ctx context.Context, chatID int) (domain.SecretChat, bool, error) {
return s.store.GetSecretChat(ctx, chatID)
}
// DiscardForAuthKey 级联 discard 绑定该设备 perm auth_key作为 admin 或 participant
// 全部活跃密聊,用于设备登出 / 授权撤销。返回本次实际从非终态迁移到 discarded 的密聊快照
// (已是终态的不返回),供 rpc 层据此向对端推送 encryptedChatDiscarded。盲中继不删历史
// history_deleted=false对端自行决定本地处置。出错时返回已成功 discard 的部分 + err
// 让调用方仍能通知这部分对端(登出/撤销是 best-effort不因此回退
func (s *Service) DiscardForAuthKey(ctx context.Context, authKeyID int64) ([]domain.SecretChat, error) {
if authKeyID == 0 {
return nil, nil
}
chats, err := s.store.ListActiveSecretChatsByAuthKey(ctx, authKeyID)
if err != nil {
return nil, err
}
var discarded []domain.SecretChat
for _, c := range chats {
updated, already, derr := s.store.DiscardSecretChat(ctx, c.ID, false)
if derr != nil {
if errors.Is(derr, domain.ErrSecretChatNotFound) {
continue
}
return discarded, derr
}
if !already {
discarded = append(discarded, updated)
}
}
return discarded, nil
}
// SendEncrypted 受理 sendEncrypted*:定位 + 发送方绑定设备/access_hash 校验 + 态须 normal →
// 给【对端绑定设备】分配 qts 并把不透明 bytes 写入投递队列(幂等:同 chat+random_id 返既有
// qts/date。返回密聊快照 + 已落库消息(携 qts/daterpc 层据此推 updateNewEncryptedMessage
// 并回 SentEncryptedMessage{date})。盲中継:不解密 bytes。
func (s *Service) SendEncrypted(ctx context.Context, chatID int, viewerUserID, viewerAuthKeyID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error) {
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
if err != nil {
return domain.SecretChat{}, domain.SecretChatMessage{}, err
}
if !ok || !chat.HasParticipant(viewerUserID) || viewerAuthKeyID == 0 ||
chat.AuthKeyOf(viewerUserID) != viewerAuthKeyID || chat.AccessHashFor(viewerUserID) != accessHash {
return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound
}
if chat.State != domain.SecretChatStateNormal {
// 未成型 / 已销毁的密聊不能收发CHAT_ID_INVALID
return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound
}
receiverUserID := chat.PeerOf(viewerUserID)
receiverAuthKeyID := chat.AdminAuthKeyID
if chat.IsAdmin(viewerUserID) {
receiverAuthKeyID = chat.ParticipantAuthKeyID
}
if receiverUserID == 0 || receiverAuthKeyID == 0 {
return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound
}
stored, _, err := s.queue.AppendEncryptedMessage(ctx, domain.SecretChatMessage{
ReceiverAuthKeyID: receiverAuthKeyID,
ReceiverUserID: receiverUserID,
ChatID: chatID,
RandomID: delivery.RandomID,
Date: delivery.Date,
IsService: delivery.IsService,
Bytes: delivery.Bytes,
File: delivery.File,
})
if err != nil {
return domain.SecretChat{}, domain.SecretChatMessage{}, err
}
return chat, stored, nil
}
// ListNewMessages 返回某设备 qts > sinceQts 的连续加密消息getDifference 补差分用)。
func (s *Service) ListNewMessages(ctx context.Context, deviceAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) {
if deviceAuthKeyID == 0 {
return nil, nil
}
return s.queue.ListEncryptedMessagesSince(ctx, deviceAuthKeyID, sinceQts, limit)
}
// DeviceReservedQts 返回某设备当前已分配的最高 qtsgetState 用)。
func (s *Service) DeviceReservedQts(ctx context.Context, deviceAuthKeyID int64) (int, error) {
if deviceAuthKeyID == 0 {
return 0, nil
}
return s.queue.ReservedQts(ctx, deviceAuthKeyID)
}
// AckQueue 推进某设备的 confirmed qts 并标记 ackedreceivedQueue
func (s *Service) AckQueue(ctx context.Context, deviceAuthKeyID int64, maxQts int) error {
if deviceAuthKeyID == 0 || maxQts <= 0 {
return nil
}
return s.queue.AckEncryptedMessages(ctx, deviceAuthKeyID, maxQts)
}
// AppendChannelParticipantEvent durably enqueues a channel-membership
// self-notification for one device, reserving that device's next qts.
func (s *Service) AppendChannelParticipantEvent(ctx context.Context, ev domain.DeviceChannelParticipantEvent) (domain.DeviceChannelParticipantEvent, error) {
if s.channelParticipants == nil || ev.ReceiverAuthKeyID == 0 {
return domain.DeviceChannelParticipantEvent{}, nil
}
return s.channelParticipants.AppendChannelParticipantEvent(ctx, ev)
}
// ListChannelParticipantEventsSince returns a device's channel-membership
// self-notifications with qts > sinceQts (getDifference catch-up).
func (s *Service) ListChannelParticipantEventsSince(ctx context.Context, deviceAuthKeyID int64, sinceQts, limit int) ([]domain.DeviceChannelParticipantEvent, error) {
if s.channelParticipants == nil || deviceAuthKeyID == 0 {
return nil, nil
}
return s.channelParticipants.ListChannelParticipantEventsSince(ctx, deviceAuthKeyID, sinceQts, limit)
}
// AckChannelParticipantEvents marks a device's channel-membership
// self-notifications up to maxQts as delivered (GC only; confirmed_qts itself
// advances via AckQueue against the shared watermark).
func (s *Service) AckChannelParticipantEvents(ctx context.Context, deviceAuthKeyID int64, maxQts int) error {
if s.channelParticipants == nil || deviceAuthKeyID == 0 || maxQts <= 0 {
return nil
}
return s.channelParticipants.AckChannelParticipantEvents(ctx, deviceAuthKeyID, maxQts)
}
// RecordEncryptionEvent 写入 durable updateEncryption 状态事件(离线补偿)。
// targetAuthKeyID=0 表示账号级(建链前邀请/撤回对 target 所有设备可见),非 0 表示
// 绑定设备定向。投递时按 secret_chats 权威态重建(不固化快照)。
func (s *Service) RecordEncryptionEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, date int) error {
if targetUserID == 0 {
return nil
}
_, err := s.queue.AppendStateEvent(ctx, domain.EncryptedStateEvent{
TargetUserID: targetUserID,
TargetAuthKeyID: targetAuthKeyID,
ChatID: chatID,
Type: domain.EncryptedStateEventEncryption,
Date: date,
})
return err
}
// RecordReadEvent 写入 durable updateEncryptedMessagesRead 状态事件(离线补偿,设备定向)。
func (s *Service) RecordReadEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, maxDate, date int) error {
if targetUserID == 0 {
return nil
}
_, err := s.queue.AppendStateEvent(ctx, domain.EncryptedStateEvent{
TargetUserID: targetUserID,
TargetAuthKeyID: targetAuthKeyID,
ChatID: chatID,
Type: domain.EncryptedStateEventRead,
MaxDate: maxDate,
Date: date,
})
return err
}
// ListStateEvents 返回某设备未投递的密聊状态事件getDifference 补偿用)。
func (s *Service) ListStateEvents(ctx context.Context, userID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) {
if userID == 0 || deviceAuthKeyID == 0 {
return nil, nil
}
return s.queue.ListUndeliveredStateEvents(ctx, userID, deviceAuthKeyID, limit)
}
// MarkStateEventsDelivered 登记某设备已投递这些状态事件。
func (s *Service) MarkStateEventsDelivered(ctx context.Context, deviceAuthKeyID int64, eventIDs []int64) error {
if deviceAuthKeyID == 0 || len(eventIDs) == 0 {
return nil
}
return s.queue.MarkStateEventsDelivered(ctx, deviceAuthKeyID, eventIDs)
}
// PutEncryptedFile 持久化密聊文件元数据快照(铸造后写一次)。
func (s *Service) PutEncryptedFile(ctx context.Context, ownerUserID int64, ref domain.EncryptedFileRef) error {
if ref.ID == 0 {
return nil
}
return s.queue.PutEncryptedFile(ctx, ownerUserID, ref)
}
// GetEncryptedFile 按 id + access_hash 回查文件快照inputEncryptedFile 复用路径)。
func (s *Service) GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) {
return s.queue.GetEncryptedFile(ctx, id, accessHash)
}
// randomAccessHash 生成正 int64 access_hashrand 8B → 高位清零保正0 置 1
func randomAccessHash() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("secretchat: access hash rand: %w", err)
}
v := int64(binary.BigEndian.Uint64(b[:]) >> 1)
if v == 0 {
v = 1
}
return v, nil
}