chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
55
internal/app/secretchat/dh.go
Normal file
55
internal/app/secretchat/dh.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Package secretchat 实现私聊端对端加密(Secret Chat / EncryptedChat)的握手
|
||||
// 状态机。服务端是盲中继:g_a/g_b/key_fingerprint/加密 bytes 全部不透明存储与
|
||||
// 原样转发,唯一参与密码学的点是对 g_a/g_b 做 DH 范围边界校验(防弱 DH/MITM)。
|
||||
// 共享密钥与明文是 E2E 客户端职责,服务端不知道也无法计算。
|
||||
// 设计见 docs/secret-chat-module.md。
|
||||
package secretchat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
|
||||
appphone "telesrv/internal/app/phone"
|
||||
)
|
||||
|
||||
// dhPubSize 是 g_a/g_b 的规范字节长度(2048-bit)。
|
||||
const dhPubSize = 256
|
||||
|
||||
// ErrGAInvalid:g_a/g_b 不在合法 DH 区间 → rpc 层映射为 DH_G_A_INVALID。
|
||||
var ErrGAInvalid = errors.New("secretchat: dh parameter invalid")
|
||||
|
||||
var (
|
||||
// dhPrimeMinusOne/边界值复用 phone 域官方 2048-bit safe prime(与 getDhConfig 下发的 p 同源)。
|
||||
dhPrimeMinusOne = new(big.Int).Sub(new(big.Int).SetBytes(appphone.DHPrime()), big.NewInt(1))
|
||||
// 2^(2048-64) 下界与 p-2^(2048-64) 上界(官方 isGoodPrime 同款边界)。
|
||||
dhLowerBound = new(big.Int).Lsh(big.NewInt(1), 2048-64)
|
||||
dhUpperBound = new(big.Int).Sub(new(big.Int).SetBytes(appphone.DHPrime()), new(big.Int).Lsh(big.NewInt(1), 2048-64))
|
||||
dhOne = big.NewInt(1)
|
||||
)
|
||||
|
||||
// validateDHParam 对 g_a/g_b 做范围校验,通过后左补零到 256 字节返回(规范线格式;
|
||||
// 首字节为 0 被裁的合法 g_a 不能误拒)。校验用的是 big.Int 值,与补零无关。
|
||||
func validateDHParam(g []byte) ([]byte, error) {
|
||||
if len(g) == 0 || len(g) > dhPubSize {
|
||||
return nil, ErrGAInvalid
|
||||
}
|
||||
x := new(big.Int).SetBytes(g)
|
||||
// 1 < x < p-1 且 2^(2048-64) < x < p-2^(2048-64)
|
||||
if x.Cmp(dhOne) <= 0 || x.Cmp(dhPrimeMinusOne) >= 0 {
|
||||
return nil, ErrGAInvalid
|
||||
}
|
||||
if x.Cmp(dhLowerBound) <= 0 || x.Cmp(dhUpperBound) >= 0 {
|
||||
return nil, ErrGAInvalid
|
||||
}
|
||||
return leftPad256(g), nil
|
||||
}
|
||||
|
||||
// leftPad256 左补零到 256 字节(输入已保证 ≤256)。
|
||||
func leftPad256(b []byte) []byte {
|
||||
if len(b) == dhPubSize {
|
||||
return append([]byte(nil), b...)
|
||||
}
|
||||
out := make([]byte, dhPubSize)
|
||||
copy(out[dhPubSize-len(b):], b)
|
||||
return out
|
||||
}
|
||||
320
internal/app/secretchat/service.go
Normal file
320
internal/app/secretchat/service.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
package secretchat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// idAllocRetries 是 chat_id 撞键自愈的有界重试次数。
|
||||
const idAllocRetries = 4
|
||||
|
||||
// Service 实现密聊握手状态机 + qts 消息投递。所有返回的 domain.SecretChat 都是当时快照。
|
||||
// 访问校验(self/bot/拉黑/隐私)在 rpc 层先行;本层做 DH 校验、id/access_hash 分配、
|
||||
// 状态机迁移与 qts 队列写入。绑定维度是设备级 perm auth_key(int64)。
|
||||
type Service struct {
|
||||
store store.SecretChatStore
|
||||
queue store.EncryptedQueueStore
|
||||
ids store.SecretChatIDAllocator
|
||||
}
|
||||
|
||||
// NewService 创建密聊服务。
|
||||
func NewService(st store.SecretChatStore, queue store.EncryptedQueueStore, ids store.SecretChatIDAllocator) *Service {
|
||||
return &Service{store: st, queue: queue, ids: ids}
|
||||
}
|
||||
|
||||
// RequestEncryption 受理 requestEncryption:校验 g_a → 幂等去重 → 分配 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
|
||||
}
|
||||
ga, err := validateDHParam(req.GA)
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
// 幂等:同发起设备 + random_id 重发返回既有 chat(DISCARDED 视为新请求)。
|
||||
if existing, ok, err := s.store.GetByAdminRandom(ctx, req.AdminAuthKeyID, req.RandomID); err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
} else if ok && !existing.Terminal() {
|
||||
return existing, nil
|
||||
}
|
||||
adminAH, err := randomAccessHash()
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
participantAH, err := randomAccessHash()
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
chat := domain.SecretChat{
|
||||
AdminAccessHash: adminAH,
|
||||
ParticipantAccessHash: participantAH,
|
||||
AdminUserID: req.AdminUserID,
|
||||
ParticipantUserID: req.ParticipantUserID,
|
||||
AdminAuthKeyID: req.AdminAuthKeyID,
|
||||
State: domain.SecretChatStateRequested,
|
||||
GA: ga,
|
||||
RandomID: req.RandomID,
|
||||
Date: req.Date,
|
||||
}
|
||||
for attempt := 0; ; attempt++ {
|
||||
chatID, err := s.nextChatID(ctx, attempt)
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
chat.ID = chatID
|
||||
err = s.store.CreateSecretChat(ctx, chat)
|
||||
if err == nil {
|
||||
return chat, nil
|
||||
}
|
||||
if errors.Is(err, domain.ErrSecretChatIDConflict) && attempt < idAllocRetries {
|
||||
continue
|
||||
}
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// nextChatID 分配下一个 chat_id;撞键后用 AtLeast(MaxSecretChatID) 顶起计数器自愈。
|
||||
// 校验 int32 正区间上界(EncryptedChat.ID 是 int32 量级)。
|
||||
func (s *Service) nextChatID(ctx context.Context, attempt int) (int, error) {
|
||||
var (
|
||||
id int
|
||||
err error
|
||||
)
|
||||
if attempt == 0 {
|
||||
id, err = s.ids.NextSecretChatID(ctx)
|
||||
} else {
|
||||
floor, ferr := s.store.MaxSecretChatID(ctx)
|
||||
if ferr != nil {
|
||||
return 0, ferr
|
||||
}
|
||||
id, err = s.ids.NextSecretChatIDAtLeast(ctx, floor)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if id <= 0 || id > 0x7fffffff {
|
||||
return 0, fmt.Errorf("secretchat: chat id out of int32 range: %d", id)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// AcceptEncryption 受理 acceptEncryption:定位 + participant 视角 access_hash 校验 →
|
||||
// 校验 g_b → 原子 CAS 绑定接受设备并落 g_b/key_fingerprint → normal。返回的密聊由
|
||||
// rpc 层投影为 participant 视角 encryptedChat(GAOrB=g_a,同步响应)与 admin 视角
|
||||
// encryptedChat(GAOrB=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 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) {
|
||||
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/date,rpc 层据此推 updateNewEncryptedMessage
|
||||
// 并回 SentEncryptedMessage{date})。盲中継:不解密 bytes。
|
||||
func (s *Service) SendEncrypted(ctx context.Context, chatID int, viewerUserID, 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) || 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 返回某设备当前已分配的最高 qts(getState 用)。
|
||||
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 并标记 acked(receivedQueue)。
|
||||
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)
|
||||
}
|
||||
|
||||
// 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_hash(rand 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
|
||||
}
|
||||
346
internal/app/secretchat/service_test.go
Normal file
346
internal/app/secretchat/service_test.go
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
package secretchat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// fakeChatIDAllocator 是单调自增的测试分配器(无 Redis)。
|
||||
type fakeChatIDAllocator struct{ n int }
|
||||
|
||||
func (a *fakeChatIDAllocator) NextSecretChatID(context.Context) (int, error) {
|
||||
a.n++
|
||||
return a.n, nil
|
||||
}
|
||||
|
||||
func (a *fakeChatIDAllocator) NextSecretChatIDAtLeast(_ context.Context, floor int) (int, error) {
|
||||
if a.n < floor {
|
||||
a.n = floor
|
||||
}
|
||||
a.n++
|
||||
return a.n, nil
|
||||
}
|
||||
|
||||
func (a *fakeChatIDAllocator) CurrentSecretChatID(context.Context) (int, error) { return a.n, nil }
|
||||
|
||||
// validGA 返回一个落在合法 DH 区间的 256 字节 g_a(首字节 0x55 ≈ 2^2046,
|
||||
// 既 > 2^1984 又 < p≈0xc7..)。
|
||||
func validGA() []byte {
|
||||
b := make([]byte, 256)
|
||||
for i := range b {
|
||||
b[i] = 0x42
|
||||
}
|
||||
b[0] = 0x55
|
||||
return b
|
||||
}
|
||||
|
||||
func newTestService() (*Service, *memory.SecretChatStore) {
|
||||
st := memory.NewSecretChatStore()
|
||||
return NewService(st, memory.NewEncryptedQueueStore(), &fakeChatIDAllocator{}), st
|
||||
}
|
||||
|
||||
const (
|
||||
adminUser = int64(1001)
|
||||
partUser = int64(2002)
|
||||
adminAuthKey = int64(0x1111)
|
||||
partAuthKey = int64(0x2222)
|
||||
keyFP = int64(0x0123456789abcdef)
|
||||
)
|
||||
|
||||
func requestFixture() domain.SecretChatRequest {
|
||||
return domain.SecretChatRequest{
|
||||
AdminUserID: adminUser,
|
||||
AdminAuthKeyID: adminAuthKey,
|
||||
ParticipantUserID: partUser,
|
||||
RandomID: 12345,
|
||||
GA: validGA(),
|
||||
Date: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryption(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, err := svc.RequestEncryption(ctx, requestFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("RequestEncryption: %v", err)
|
||||
}
|
||||
if chat.ID <= 0 || chat.ID > 0x7fffffff {
|
||||
t.Fatalf("chat id out of int32 range: %d", chat.ID)
|
||||
}
|
||||
if chat.State != domain.SecretChatStateRequested {
|
||||
t.Fatalf("state = %q, want requested", chat.State)
|
||||
}
|
||||
if len(chat.GA) != dhPubSize {
|
||||
t.Fatalf("g_a length = %d, want %d (left-padded)", len(chat.GA), dhPubSize)
|
||||
}
|
||||
if chat.AdminAccessHash == 0 || chat.ParticipantAccessHash == 0 {
|
||||
t.Fatal("access hashes must be non-zero")
|
||||
}
|
||||
if chat.AdminAccessHash == chat.ParticipantAccessHash {
|
||||
t.Fatal("admin/participant access hashes must differ (per-viewer)")
|
||||
}
|
||||
if chat.ParticipantAuthKeyID != 0 {
|
||||
t.Fatalf("participant auth key must be unbound before accept, got %d", chat.ParticipantAuthKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryptionIdempotent(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
first, err := svc.RequestEncryption(ctx, requestFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("first request: %v", err)
|
||||
}
|
||||
second, err := svc.RequestEncryption(ctx, requestFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("idempotent re-request must return same chat: %d vs %d", first.ID, second.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryptionInvalidGA(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
req := requestFixture()
|
||||
req.GA = []byte{0x01} // value 1 → 不在 (1, p-1)
|
||||
if _, err := svc.RequestEncryption(context.Background(), req); !errors.Is(err, ErrGAInvalid) {
|
||||
t.Fatalf("err = %v, want ErrGAInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptEncryption(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, err := svc.RequestEncryption(ctx, requestFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
accepted, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP)
|
||||
if err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if accepted.State != domain.SecretChatStateNormal {
|
||||
t.Fatalf("state = %q, want normal", accepted.State)
|
||||
}
|
||||
if accepted.KeyFingerprint != keyFP {
|
||||
t.Fatalf("key fingerprint not relayed byte-for-byte: got %x want %x", accepted.KeyFingerprint, keyFP)
|
||||
}
|
||||
if accepted.ParticipantAuthKeyID != partAuthKey {
|
||||
t.Fatalf("participant auth key not bound: %d", accepted.ParticipantAuthKeyID)
|
||||
}
|
||||
if len(accepted.GB) != dhPubSize {
|
||||
t.Fatalf("g_b length = %d, want %d", len(accepted.GB), dhPubSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptEncryptionWrongAccessHash(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash+1, validGA(), keyFP)
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptEncryptionWrongUser(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
// admin 自己冒充接受方。
|
||||
_, err := svc.AcceptEncryption(ctx, chat.ID, adminUser, adminAuthKey, chat.ParticipantAccessHash, validGA(), keyFP)
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptEncryptionDoubleAccept(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
if _, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP); err != nil {
|
||||
t.Fatalf("first accept: %v", err)
|
||||
}
|
||||
// 第二台设备 accept:CAS 落空 → ENCRYPTION_ALREADY_ACCEPTED。
|
||||
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, int64(0x3333), chat.ParticipantAccessHash, validGA(), keyFP)
|
||||
if !errors.Is(err, domain.ErrSecretChatAlreadyAccepted) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatAlreadyAccepted", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptEncryptionInvalidGB(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, []byte{0x01}, keyFP)
|
||||
if !errors.Is(err, ErrGAInvalid) {
|
||||
t.Fatalf("err = %v, want ErrGAInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardEncryption(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
got, already, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, true)
|
||||
if err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if already {
|
||||
t.Fatal("first discard must not report already")
|
||||
}
|
||||
if got.State != domain.SecretChatStateDiscarded || !got.HistoryDeleted {
|
||||
t.Fatalf("discarded chat = %+v", got)
|
||||
}
|
||||
// 幂等:再 discard 返回 already=true。
|
||||
_, already, err = svc.DiscardEncryption(ctx, chat.ID, partUser, false)
|
||||
if err != nil || !already {
|
||||
t.Fatalf("idempotent discard: already=%v err=%v", already, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardEncryptionNonParticipant(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
_, _, err := svc.DiscardEncryption(ctx, chat.ID, int64(9999), false)
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// acceptedChat 跑完 request→accept,返回 normal 态密聊。
|
||||
func acceptedChat(t *testing.T, svc *Service) domain.SecretChat {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
chat, err := svc.RequestEncryption(ctx, requestFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
accepted, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, dhParamGB(), keyFP)
|
||||
if err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func dhParamGB() []byte {
|
||||
b := make([]byte, 256)
|
||||
for i := range b {
|
||||
b[i] = 0x42
|
||||
}
|
||||
b[0] = 0x66
|
||||
return b
|
||||
}
|
||||
|
||||
func TestSendEncryptedQtsAllocation(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat := acceptedChat(t, svc)
|
||||
|
||||
// admin 发 → 投给 participant 设备(partAuthKey),qts 从 1 起。
|
||||
_, m1, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("send 1: %v", err)
|
||||
}
|
||||
if m1.Qts != 1 || m1.ReceiverAuthKeyID != partAuthKey || m1.ReceiverUserID != partUser {
|
||||
t.Fatalf("msg1 = %+v (want qts=1, receiver=participant device)", m1)
|
||||
}
|
||||
_, m2, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 222, Bytes: []byte{4}, Date: 2001})
|
||||
if err != nil || m2.Qts != 2 {
|
||||
t.Fatalf("msg2 qts = %d err=%v, want 2", m2.Qts, err)
|
||||
}
|
||||
|
||||
// 幂等重发同 random_id → 返回首次 qts/date,不分配新 qts。
|
||||
_, dup, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 9999})
|
||||
if err != nil {
|
||||
t.Fatalf("dup send: %v", err)
|
||||
}
|
||||
if dup.Qts != 1 || dup.Date != 2000 {
|
||||
t.Fatalf("idempotent resend = %+v, want qts=1 date=2000 (首次落库值)", dup)
|
||||
}
|
||||
|
||||
// participant 发 → 投给 admin 设备(adminAuthKey),独立 qts 序列从 1 起。
|
||||
_, pm, err := svc.SendEncrypted(ctx, chat.ID, partUser, chat.ParticipantAccessHash, domain.SecretMessageDelivery{RandomID: 333, Bytes: []byte{9}, Date: 2002})
|
||||
if err != nil {
|
||||
t.Fatalf("participant send: %v", err)
|
||||
}
|
||||
if pm.Qts != 1 || pm.ReceiverAuthKeyID != adminAuthKey || pm.ReceiverUserID != adminUser {
|
||||
t.Fatalf("participant msg = %+v (want qts=1, receiver=admin device)", pm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEncryptedWrongAccessHash(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat := acceptedChat(t, svc)
|
||||
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash+1, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEncryptedNonNormal(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture()) // requested, 未 accept
|
||||
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound (未成型不能发)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNewMessagesAndAck(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat := acceptedChat(t, svc)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: int64(1000 + i), Bytes: []byte{byte(i)}, Date: 2000 + i}); err != nil {
|
||||
t.Fatalf("send %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// 接收设备(participant)补差分:qts>0 全部 3 条。
|
||||
msgs, err := svc.ListNewMessages(ctx, partAuthKey, 0, 0)
|
||||
if err != nil || len(msgs) != 3 {
|
||||
t.Fatalf("list since 0 = %d msgs err=%v, want 3", len(msgs), err)
|
||||
}
|
||||
if msgs[0].Qts != 1 || msgs[2].Qts != 3 {
|
||||
t.Fatalf("qts sequence broken: %d..%d", msgs[0].Qts, msgs[2].Qts)
|
||||
}
|
||||
// qts>1 → 剩 2 条。
|
||||
msgs, _ = svc.ListNewMessages(ctx, partAuthKey, 1, 0)
|
||||
if len(msgs) != 2 || msgs[0].Qts != 2 {
|
||||
t.Fatalf("list since 1 = %+v, want qts 2,3", msgs)
|
||||
}
|
||||
// reserved qts = 3。
|
||||
if q, _ := svc.DeviceReservedQts(ctx, partAuthKey); q != 3 {
|
||||
t.Fatalf("reserved qts = %d, want 3", q)
|
||||
}
|
||||
// ack 到 3:不报错(confirmed 推进)。
|
||||
if err := svc.AckQueue(ctx, partAuthKey, 3); err != nil {
|
||||
t.Fatalf("ack: %v", err)
|
||||
}
|
||||
// 未参与设备 qts=0。
|
||||
if q, _ := svc.DeviceReservedQts(ctx, int64(0xDEAD)); q != 0 {
|
||||
t.Fatalf("unrelated device reserved qts = %d, want 0", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptAfterDiscard(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
if _, _, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, false); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP)
|
||||
if !errors.Is(err, domain.ErrSecretChatAlreadyDeclined) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatAlreadyDeclined", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue