Merge remote-tracking branch 'upstream/main' into dev

This commit is contained in:
onysd 2026-07-18 09:19:27 +03:00
commit 6b29556ef8
836 changed files with 1598388 additions and 64684 deletions

View file

@ -14,11 +14,11 @@ import (
"unicode/utf8"
"github.com/gotd/ige"
"github.com/gotd/td/bin"
mtcrypto "github.com/gotd/td/crypto"
"github.com/iamxvbaba/td/bin"
mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/domain"
"telesrv/internal/mail"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
@ -27,6 +27,10 @@ var (
ErrCodeExpired = errors.New("phone code expired or not found")
ErrCodeInvalid = errors.New("phone code invalid")
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
ErrExpiresAtInvalid = errors.New("temporary auth key request expiry invalid")
ErrTempAuthKeyEmpty = errors.New("temporary auth key missing or expired")
ErrTempAuthKeyAlreadyBound = errors.New("temporary auth key already bound")
ErrAuthKeyPermEmpty = errors.New("permanent auth key required")
// ErrLoginCodeDeliveryUnavailable 表示已有账号的 app-code 没有可用的
// durable message/event/outbox 投递边界。这是服务端配置错误,不能降级成
// “继续返回 sentCode,等 signIn 后补发”。
@ -44,9 +48,10 @@ var (
)
const (
codeChannelPhone = "phone"
codeChannelEmailLogin = "email_login"
codeChannelEmailSetupRequired = "email_setup_required"
codeChannelPhone = store.PhoneCodeChannelPhone
codeChannelSMS = store.PhoneCodeChannelSMS
codeChannelEmailLogin = store.PhoneCodeChannelEmailLogin
codeChannelEmailSetupRequired = store.PhoneCodeChannelEmailSetupRequired
loginCodeRollbackTimeout = 2 * time.Second
)
@ -66,7 +71,9 @@ func systemLoginPhoneForbidden(phone string) bool {
return ok
}
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
// Service 实现登录/注册业务。默认保留开发固定码;配置外部 provider
// 后生成随机验证码并通过 otpdelivery 投递。已有账号的外部投递是 durable
// 777000 App-code 的附加渠道,不能替换或削弱原有消息事实。
type Service struct {
users store.UserStore
auths store.AuthorizationStore
@ -82,7 +89,10 @@ type Service struct {
codeTTL time.Duration
codeMaxAttempts int
loginEmails loginEmailStore
loginEmailSender mail.Sender
loginEmailSender otpdelivery.Sender
phoneCodeSender otpdelivery.Sender
otpDeliveryFailure func(context.Context, otpdelivery.Request, error)
phoneCodeLength int
loginEmailEnabled bool
loginEmailRequireSetup bool
loginEmailCodeLength int
@ -109,7 +119,7 @@ type LoginEmailOptions struct {
RequireSetup bool
CodeLength int
Store loginEmailStore
Sender mail.Sender
Sender otpdelivery.Sender
}
type authorizationRevoker interface {
@ -207,9 +217,34 @@ func WithEmailSignupPhonePrefixes(prefixes []string) Option {
}
}
// WithPhoneCodeDelivery enables an external SMS delivery provider. Existing
// accounts keep their durable 777000 App-code and receive the same code through
// the provider as an additional channel. A nil sender preserves development
// behavior.
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) Option {
return func(s *Service) {
s.phoneCodeSender = sender
if length > 0 {
s.phoneCodeLength = length
}
}
}
// WithOTPDeliveryFailureObserver observes failures of an additional provider
// delivery after an existing account already has a durable 777000 App-code.
// Observers must not log the recipient or code.
func WithOTPDeliveryFailureObserver(observer func(context.Context, otpdelivery.Request, error)) Option {
return func(s *Service) {
s.otpDeliveryFailure = observer
}
}
// NewService 创建登录服务。fixedCode 为开发固定验证码。
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6, phoneCodeLength: 5}
if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil {
linker.LinkAuthKeyAuthority(authKeys)
}
for _, opt := range opts {
opt(s)
}
@ -219,26 +254,44 @@ func NewService(users store.UserStore, auths store.AuthorizationStore, codes sto
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
if s.authKeys != nil {
inner, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
if err != nil {
return err
}
binding.TempSessionID = inner.TempSessionID
// The bind request's expires_at is a signed client assertion. TDesktop
// intentionally adds a small grace interval, while Android derives its
// value at handshake completion. Retention and edge admission must use the
// server's p_q_inner_data_temp lifetime, never the client value.
binding.ExpiresAt = protocolExpiresAt
}
if binding.ExpiresAt <= int(time.Now().Unix()) {
// The edge may admit the frame immediately before the temporary key's
// absolute boundary and the encrypted proof may cross it. This is a temp-key
// rotation condition, never a destructive permanent-key proof failure.
return ErrTempAuthKeyEmpty
}
if s.tempKeys == nil {
return nil
}
return s.tempKeys.Save(ctx, binding)
if err := s.tempKeys.Save(ctx, binding); err != nil {
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
return ErrTempAuthKeyAlreadyBound
}
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
return s.classifyBindingStoreInvalid(ctx, binding)
}
return err
}
return nil
}
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
//
// 过期处理是有意的连续性权衡(见 TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey):
// temp 绑定 expires_at 已过时,仅当 perm key 也未授权才拒绝;perm 仍授权则继续解析,
// 避免已登录会话因 temp key 过期而被强制踢下线。严格 PFS 要求过期 temp key 一律失效
// (不以 perm 授权豁免),但收紧前需先核实目标客户端(TDesktop/DrKLO)会在过期前主动
// 轮换 temp key 并优雅处理拒绝,否则会造成在线会话掉线。RetentionWorker 的 DeleteExpired
// 已把残留窗口限制在 expires_at + 宽限(约 24h)内。收紧为显式硬化任务,需客户端验证。
// temp→perm 是握手/绑定形成的协议身份关系,与 perm 当前是否登录完全无关。即使
// auth.logOut 已删除 authorization,只要绑定仍存在,后续登录 RPC 也必须继续落到同一
// perm key,绝不能把 raw temp key 当成新的业务身份。协议过期由 mtprotoedge 在解密/RPC
// 之前返回 -404 并关闭连接;这里不再用 authorization 状态猜测 key 类型。
func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) {
if s == nil || s.tempKeys == nil {
return [8]byte{}, false, nil
@ -247,19 +300,7 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
if err != nil || !found {
return [8]byte{}, found, err
}
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
if binding.ExpiresAt <= int(time.Now().Unix()) && !s.permAuthKeyAuthorized(ctx, permID) {
return [8]byte{}, false, nil
}
return permID, true, nil
}
func (s *Service) permAuthKeyAuthorized(ctx context.Context, authKeyID [8]byte) bool {
if s == nil || s.auths == nil {
return false
}
_, found, err := s.auths.ByAuthKey(ctx, authKeyID)
return err == nil && found
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
}
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
@ -430,32 +471,68 @@ func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUse
if err != nil {
return "", err
}
if err := s.codes.Set(ctx, hash, store.PhoneCode{
code := s.fixedCode
channel := codeChannelPhone
deliveryID := ""
if s.phoneCodeSender != nil {
code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", err
}
deliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
channel = codeChannelSMS
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: existingUserID,
Phone: phone,
Code: s.fixedCode,
Channel: codeChannelPhone,
Code: code,
DeliveryID: deliveryID,
Channel: channel,
MaxAttempts: s.codeMaxAttempts,
}, s.codeTTL); err != nil {
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store code: %w", err)
}
rec := store.PhoneCode{Phone: phone, IssuedUserID: existingUserID}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
// 新手机号还没有 owner/dialog,只能在 SignUp 创建用户后写第一条
// 777000 消息。已有账号则必须在 sendCode RPC 返回前把 app-code
// 作为普通 incoming message + durable update/outbox 提交;登录成功不再补发。
if existingUserID == 0 {
// Existing accounts always retain the original durable App-code path. Commit
// it before attempting the external mirror so a provider cannot replace the
// message fact or leave an externally disclosed code without local state.
if existingUserID != 0 {
if err := s.deliverLoginCode(ctx, existingUserID, hash, code); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
}
if s.phoneCodeSender != nil {
request := otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginSMS,
Channel: otpdelivery.ChannelSMS,
Recipient: phone,
Code: code,
ExpiresAt: expiresAt,
}
if existingUserID != 0 {
s.deliverOTPWithAppFallback(ctx, s.phoneCodeSender, request)
} else if err := deliverOTP(ctx, s.phoneCodeSender, request); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login SMS code: %w", err))
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
return hash, nil
}
if err := s.deliverLoginCode(ctx, existingUserID, hash, s.fixedCode); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
// 新手机号还没有 owner/dialog,不能在签发阶段创建 777000 消息;
// 已有账号的 App-code 已在上面的 provider 分支之前 durable 提交。
return hash, nil
}
@ -521,25 +598,60 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
if err != nil {
return "", err
}
deliveryID, err := otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: issuedUserID,
Phone: phone,
Code: code,
DeliveryID: deliveryID,
Channel: codeChannelEmailLogin,
Email: strings.TrimSpace(email),
MaxAttempts: s.codeMaxAttempts,
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store email code: %w", err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
if issuedUserID != 0 {
if err := s.deliverLoginCode(ctx, issuedUserID, hash, code); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
}
if s.loginEmailSender == nil {
if issuedUserID != 0 {
s.reportOTPDeliveryFailure(ctx, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginEmail,
Channel: otpdelivery.ChannelEmail,
Recipient: rec.Email,
Code: code,
ExpiresAt: expiresAt,
}, fmt.Errorf("login email sender is not configured"))
return hash, nil
}
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("login email sender is not configured"))
}
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
request := otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginEmail,
Channel: otpdelivery.ChannelEmail,
Recipient: rec.Email,
Code: code,
ExpiresAt: expiresAt,
}
if issuedUserID != 0 {
s.deliverOTPWithAppFallback(ctx, s.loginEmailSender, request)
} else if err := deliverOTP(ctx, s.loginEmailSender, request); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login email code: %w", err))
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
@ -548,6 +660,34 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
return hash, nil
}
// deliverOTPWithAppFallback performs an additional provider delivery only
// after the same code is durably visible through 777000. A provider failure
// must not invalidate that visible code or fail the RPC; it remains observable
// through the injected failure observer.
func (s *Service) deliverOTPWithAppFallback(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) {
if _, err := sender.Deliver(ctx, req); err != nil {
s.reportOTPDeliveryFailure(ctx, req, err)
}
}
func (s *Service) reportOTPDeliveryFailure(ctx context.Context, req otpdelivery.Request, err error) {
if s.otpDeliveryFailure != nil && err != nil {
s.otpDeliveryFailure(ctx, req, err)
}
}
// deliverOTP treats a transport-level unknown outcome as a successful issue:
// the provider may already have accepted the request, so the code must remain
// usable and the client needs the hash in order to verify or explicitly resend
// it. Only an explicit provider rejection is safe to roll back.
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
_, err := sender.Deliver(ctx, req)
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
return nil
}
return err
}
func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error) {
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil || !found {
@ -561,6 +701,8 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
}
switch rec.Channel {
case codeChannelSMS:
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
case codeChannelEmailLogin:
return domain.AuthCodeDelivery{
Kind: domain.AuthCodeDeliveryEmail,
@ -640,7 +782,7 @@ func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phon
if rec.Channel == codeChannelEmailSetupRequired {
return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID)
}
if rec.Channel != codeChannelPhone {
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS {
return "", ErrCodeInvalid
}
return s.createPhoneCode(ctx, phone, rec.IssuedUserID)
@ -652,14 +794,44 @@ func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCo
return "", err
}
rec.Code = s.fixedCode
rec.DeliveryID = ""
rec.Channel = codeChannelPhone
if s.phoneCodeSender != nil {
rec.Code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", err
}
rec.DeliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
rec.Channel = codeChannelSMS
}
rec.Attempts = 0
if rec.MaxAttempts <= 0 {
rec.MaxAttempts = s.codeMaxAttempts
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store resent phone change code: %w", err)
}
if s.phoneCodeSender != nil {
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
DeliveryID: rec.DeliveryID,
Purpose: otpdelivery.PurposeChangePhone,
Channel: otpdelivery.ChannelSMS,
Recipient: rec.Phone,
Code: rec.Code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
defer cancel()
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
return "", errors.Join(err, fmt.Errorf("rollback undelivered phone change code: %w", cleanupErr))
}
return "", err
}
}
return hash, nil
}
@ -794,7 +966,7 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
if systemLoginPhoneForbidden(phone) {
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
}
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code, false)
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code)
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
@ -804,17 +976,17 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
return s.finishSignIn(ctx, auth, existing)
}
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
// 随机邮箱码;未开启该特性时仍允许旧客户端把 phone channel 放进
// email_verification,但必须精确匹配该 phone code,不能再接受任意非空值。
// 两条路径共用 owner 绑定、原子尝试计数与 2FA 门控。
// SignInWithEmail 处理带 email_verification 的 auth.signIn。它与 SignIn
// 共享同一个登录凭证状态机:TDesktop/Android 把邮箱码放在
// email_verification,WebK 把同一邮箱码放在 phone_code;TL 字段只是 proof
// carrier,服务端签发记录的 channel 才表示实际投递渠道。所有渠道都必须精确
// 匹配签发码,并共用 owner 绑定、原子尝试计数、一次性消费与 2FA 门控。
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
phone = normalizePhone(phone)
if systemLoginPhoneForbidden(phone) {
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
}
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code), true)
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code))
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
@ -828,7 +1000,7 @@ func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization
// CodeStore verification. The phone owner is read both before and after that
// linearization point. A hash issued for an unregistered number therefore can
// never authorize whichever account happens to acquire that number later.
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string, emailPath bool) (store.PhoneCode, domain.User, bool, error) {
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string) (store.PhoneCode, domain.User, bool, error) {
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
return store.PhoneCode{}, domain.User{}, false, err
@ -843,11 +1015,7 @@ func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, cod
if rec.Phone != phone || rec.Purpose != "" {
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
}
channelAllowed := rec.Channel == codeChannelPhone && !emailPath
if emailPath {
channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone)
}
if !channelAllowed {
if !store.LoginCodeChannelVerifiable(rec.Channel) {
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
}
@ -990,7 +1158,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
if !store.LoginCodeChannelVerifiable(rec.Channel) {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
// Email-signup accounts (888-encoded phone) already proved ownership of
@ -1017,7 +1185,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
return domain.User{}, domain.Message{}, ErrCodeExpired
}
rec = consumed
if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) {
if rec.IssuedUserID != 0 || !rec.SignUpVerified || !store.LoginCodeChannelVerifiable(rec.Channel) {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
@ -1081,10 +1249,11 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
return domain.User{}, domain.Message{}, err
}
loginMessage := domain.Message{}
// SMTP setup/login codes are secret factors, not 777000 app messages. Only
// the normal phone/app-code registration path creates the bootstrap dialog
// carrying the actual code; every account additionally gets the
// welcome message below regardless of channel.
// A new account has no owner/dialog at issuance time. Only the development
// phone/App registration path creates its bootstrap 777000 message here;
// external SMS, email setup, and email-signup registration retain only
// their verified fact — every account additionally gets the welcome
// message below regardless of channel.
if rec.Channel == codeChannelPhone {
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
if err != nil {
@ -1204,13 +1373,6 @@ func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.
return s.auths.ByAuthKey(ctx, authKeyID)
}
func (s *Service) UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byte, layer int) error {
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) || layer <= 0 {
return nil
}
return s.auths.UpdateLayer(ctx, authKeyID, layer)
}
func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return domain.AuthKeyClientInfo{}, false, nil
@ -1220,12 +1382,13 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
return domain.AuthKeyClientInfo{}, found, err
}
info := domain.AuthKeyClientInfo{
Layer: key.Layer,
DeviceModel: key.DeviceModel,
Platform: key.Platform,
SystemVersion: key.SystemVersion,
APIID: key.APIID,
AppVersion: key.AppVersion,
Layer: key.Layer,
LayerObservationID: key.LayerObservationID,
DeviceModel: key.DeviceModel,
Platform: key.Platform,
SystemVersion: key.SystemVersion,
APIID: key.APIID,
AppVersion: key.AppVersion,
}
if info.Layer == 0 && info.DeviceModel == "" && info.Platform == "" &&
info.SystemVersion == "" && info.APIID == 0 && info.AppVersion == "" {
@ -1249,7 +1412,15 @@ func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte
return err
}
if s.auths != nil {
return s.auths.UpdateClientInfo(ctx, authKeyID, info)
// Layer is an ordered protocol fact. Its authorization-table mirror is
// advanced atomically by the durable Layer evidence/bind transactions.
// A generic metadata update is deliberately two-store and can race such
// a transaction, so it must never write an older Layer after the primary
// auth_keys row has already advanced.
authorizationInfo := info
authorizationInfo.Layer = 0
authorizationInfo.LayerObservationID = 0
return s.auths.UpdateClientInfo(ctx, authKeyID, authorizationInfo)
}
return nil
}
@ -1340,11 +1511,29 @@ func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64,
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
if s.authKeys != nil {
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
if err != nil {
return err
}
// Defense in depth: Router normally converts a bound temp key to its perm
// identity and edge rejects expired temp keys. Never let an unbound/sticky
// temp key create authorization even if either outer boundary regresses.
if !found || key.ExpiresAt != 0 {
return ErrAuthKeyPermEmpty
}
}
auth.UserID = userID
// Bind 是授权切换的持久化状态边界:生产 store 会先清同 auth key 的旧用户
// update state,再原子建立新用户 baseline。RPC 层不得在 Bind 成功后清整个 key,
// 否则会把刚建立的 retained-floor checkpoint 一并删除。
return s.auths.Bind(ctx, auth)
if err := s.auths.Bind(ctx, auth); err != nil {
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
return ErrAuthKeyPermEmpty
}
return err
}
return nil
}
func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error) {
@ -1418,32 +1607,60 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
})
}
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, error) {
if binding.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, ErrExpiresAtInvalid
}
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, 0, err
}
// expires_at in auth.bindTempAuthKey is client-supplied and must only attest
// to a still-live binding. It may never create or reclassify a protocol key;
// the caller normalizes durable retention to this handshake-authoritative
// temp.ExpiresAt instead of trusting the client value.
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
}
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
perm, found, err := s.authKeys.Get(ctx, permID)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, err
return mtcrypto.BindAuthKeyInner{}, 0, err
}
if !found {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
if !found || perm.ExpiresAt != 0 {
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
}
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
}
if inner.Nonce != binding.Nonce ||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
inner.TempSessionID != sessionID ||
inner.ExpiresAt != binding.ExpiresAt {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
}
return inner, nil
if temp.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
}
return inner, temp.ExpiresAt, nil
}
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
if s == nil || s.authKeys == nil {
return ErrEncryptedMessageInvalid
}
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
if err != nil {
return err
}
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
return ErrTempAuthKeyEmpty
}
return ErrEncryptedMessageInvalid
}
func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) {