owpengram-server/internal/domain/account.go
Astra f823b2cb74 rpc: return PASSWORD_MISSING for channel transfer without 2FA
messages.editChatCreator unconditionally returned PASSWORD_HASH_INVALID for
an account with no cloud password at all. Real Telegram Desktop's transfer-
ownership flow only recognizes the distinct PASSWORD_MISSING error to show
its "enable 2FA first" box; anything else falls through into the real
password-entry flow, which then has nothing to check against and crashes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:05:28 +01:00

375 lines
13 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 domain
import (
"errors"
"strings"
"time"
"unicode"
"github.com/nyaruka/phonenumbers"
)
var (
ErrPasswordHashInvalid = errors.New("password hash invalid")
ErrPasswordMissing = errors.New("password missing")
ErrSRPIDInvalid = errors.New("srp id invalid")
ErrSRPPasswordChanged = errors.New("srp password changed")
ErrNewSettingsInvalid = errors.New("new password settings invalid")
ErrNewSaltInvalid = errors.New("new password salt invalid")
ErrPasswordRecoveryNA = errors.New("password recovery not available")
ErrRecoveryCodeEmpty = errors.New("recovery code empty")
ErrRecoveryCodeInvalid = errors.New("recovery code invalid")
ErrPasswordRecoveryExpired = errors.New("password recovery expired")
ErrEmailCodeInvalid = errors.New("email code invalid")
ErrEmailInvalid = errors.New("email invalid")
ErrEmailNotAllowed = errors.New("email not allowed")
ErrEmailOccupied = errors.New("email occupied")
ErrSessionPasswordNeeded = errors.New("session password needed")
ErrPhoneNumberInvalid = errors.New("phone number invalid")
ErrPhoneNumberOccupied = errors.New("phone number occupied")
ErrPhoneCodeEmpty = errors.New("phone code empty")
ErrPhoneCodeInvalid = errors.New("phone code invalid")
ErrPhoneCodeExpired = errors.New("phone code expired")
ErrPhoneChangeAuthInvalid = errors.New("phone change auth invalid")
ErrPhoneChangeForbidden = errors.New("phone change forbidden")
)
type AuthCodeDeliveryKind string
const (
AuthCodeDeliveryPhone AuthCodeDeliveryKind = "phone"
AuthCodeDeliverySMS AuthCodeDeliveryKind = "sms"
AuthCodeDeliveryEmail AuthCodeDeliveryKind = "email"
AuthCodeDeliveryEmailSetupRequired AuthCodeDeliveryKind = "email_setup_required"
)
type AuthCodeDelivery struct {
Kind AuthCodeDeliveryKind
EmailPattern string
Length int
}
// PasswordKDFAlgo 是业务层的 SRP KDF 算法描述,不依赖 tg.*。
type PasswordKDFAlgo struct {
Salt1 []byte
Salt2 []byte
G int
P []byte
}
// SecurePasswordKDFAlgo 是 Telegram Passport secure secret 的 KDF 算法描述。
type SecurePasswordKDFAlgo struct {
Kind string
Salt []byte
}
// PasswordCheck 是 inputCheckPasswordEmpty/inputCheckPasswordSRP 的业务层表达。
type PasswordCheck struct {
Empty bool
SRPID int64
A []byte
M1 []byte
}
// PasswordInputSettings 是 account.passwordInputSettings 的业务层表达。
type PasswordInputSettings struct {
NewAlgo *PasswordKDFAlgo
NewPasswordHash []byte
Hint string
HasHint bool
Email string
HasEmail bool
}
// PrivatePasswordSettings 是 account.passwordSettings 的业务层表达。
type PrivatePasswordSettings struct {
Email string
}
type PasswordResetKind string
const (
PasswordResetOK PasswordResetKind = "ok"
PasswordResetRequestedWait PasswordResetKind = "requested_wait"
PasswordResetFailedWait PasswordResetKind = "failed_wait"
)
type PasswordResetResult struct {
Kind PasswordResetKind
UntilDate int
RetryDate int
}
// PasswordSettings 是账号 2FA/SRP 配置。默认 HasPassword=false。
type PasswordSettings struct {
HasRecovery bool
HasSecureValues bool
HasPassword bool
CurrentAlgo *PasswordKDFAlgo
SRPB []byte
SRPID int64
Hint string
EmailUnconfirmedPattern string
RecoveryEmail string
// LoginEmail 是已确认的登录邮箱地址(服务端私有,永不直接下发;下发的是掩码后的
// LoginEmailPattern。它独立于 2FA 恢复邮箱 RecoveryEmail账号可只设登录邮箱而无 2FA。
LoginEmail string
LoginEmailPattern string
NewAlgo PasswordKDFAlgo
NewSecureAlgo SecurePasswordKDFAlgo
SecureRandom []byte
PendingResetDate int
// Server-only SRP fields. They are persisted but never exposed to rpc/tg conversion.
SRPVerifier []byte
SRPBSecret []byte
}
// RevenueWithdrawalPasswordState carries only the durable 2FA facts required
// by high-risk payout admission. It intentionally excludes password material.
type RevenueWithdrawalPasswordState struct {
HasPassword bool
PasswordChangedAt time.Time
}
// ReactionNotifyFrom stores one account-level reaction notification scope.
type ReactionNotifyFrom string
const (
ReactionNotifyFromNone ReactionNotifyFrom = "none"
ReactionNotifyFromContacts ReactionNotifyFrom = "contacts"
ReactionNotifyFromAll ReactionNotifyFrom = "all"
)
// ReactionsNotifySettings stores the account reaction notification settings
// consumed by account.get/setReactionsNotifySettings.
type ReactionsNotifySettings struct {
MessagesFrom ReactionNotifyFrom
StoriesFrom ReactionNotifyFrom
PollVotesFrom ReactionNotifyFrom
ShowPreviews bool
}
// PaidReactionPrivacyKind stores the account default paid reaction privacy.
type PaidReactionPrivacyKind string
const (
PaidReactionPrivacyDefault PaidReactionPrivacyKind = "default"
PaidReactionPrivacyAnonymous PaidReactionPrivacyKind = "anonymous"
PaidReactionPrivacyPeer PaidReactionPrivacyKind = "peer"
)
// PaidReactionPrivacy is the domain representation of tg.PaidReactionPrivacy.
type PaidReactionPrivacy struct {
Kind PaidReactionPrivacyKind
Peer *Peer
}
// AccountReactionSettings groups account-level reaction preferences.
type AccountReactionSettings struct {
Notify ReactionsNotifySettings
DefaultReaction MessageReaction
PaidPrivacy PaidReactionPrivacy
}
func DefaultAccountReactionSettings() AccountReactionSettings {
return AccountReactionSettings{
Notify: ReactionsNotifySettings{
MessagesFrom: ReactionNotifyFromContacts,
StoriesFrom: ReactionNotifyFromContacts,
PollVotesFrom: ReactionNotifyFromContacts,
ShowPreviews: true,
},
DefaultReaction: MessageReaction{Type: MessageReactionEmoji, Emoticon: "👍"},
PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault},
}
}
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
const (
DefaultAccountTTLDays = 365
// MaxAccountTTLDays prevents an untrusted int32 TL value from producing an
// out-of-range PostgreSQL interval/timestamp during deadline maintenance.
MaxAccountTTLDays = 3650
)
// DisallowedGifts stores the Layer 228 global gift-reception switches.
type DisallowedGifts struct {
UnlimitedStargifts bool
LimitedStargifts bool
UniqueStargifts bool
PremiumGifts bool
StargiftsFromChannel bool
}
func (g DisallowedGifts) Zero() bool {
return !g.UnlimitedStargifts && !g.LimitedStargifts && !g.UniqueStargifts &&
!g.PremiumGifts && !g.StargiftsFromChannel
}
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
type GlobalPrivacy struct {
ArchiveAndMuteNewNoncontactPeers bool
KeepArchivedUnmuted bool
KeepArchivedFolders bool
HideReadMarks bool
NewNoncontactPeersRequirePremium bool
DisplayGiftsButton bool
DisallowedGifts DisallowedGifts
// NoncontactPeersPaidStars非联系人给本人发消息所需 Stars 数。Stars 账本尚未实现,
// 此处仅做忠实持久化(往返不丢值),不参与计费逻辑。
NoncontactPeersPaidStars int64
}
// AccountSettings 聚合账号级单例设置(每用户一行):全局隐私、账号自毁期限、
// 敏感内容开关、联系人注册通知静音。对应 account.get/set GlobalPrivacySettings、
// get/set AccountTTL、get/set ContentSettings、get/set ContactSignUpNotification。
type AccountSettings struct {
GlobalPrivacy GlobalPrivacy
AccountTTLDays int
SensitiveContentEnabled bool
// ContactSignUpSilent 对应 account.setContactSignUpNotification 的 silent 形参:
// true=联系人注册时不通知本人。getContactSignUpNotification 直接返回该值。
ContactSignUpSilent bool
}
// DefaultAccountSettings 是未持久化时的账号设置默认值(与历史回显 stub 行为一致:
// 全局隐私全关、TTL 365 天、敏感内容关、联系人注册通知开启)。
func DefaultAccountSettings() AccountSettings {
return AccountSettings{
AccountTTLDays: DefaultAccountTTLDays,
}
}
// NormalizedTTLDays 返回钳制后的账号自毁期限0/越界回落默认)。
func (s AccountSettings) NormalizedTTLDays() int {
if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays {
return DefaultAccountTTLDays
}
return s.AccountTTLDays
}
// MaskEmail 把邮箱地址按 Telegram pattern 习惯掩码(首尾各保留一位本地名,如
// a***z@x.com用于 account.password.login_email_pattern / auth.sentCodeTypeEmailCode
// 等只能暴露掩码的下发点。空串返回空串。
func MaskEmail(email string) string {
if email == "" {
return ""
}
at := strings.Index(email, "@")
if at <= 1 {
return email
}
name := email[:at]
return name[:1] + "***" + name[len(name)-1:] + email[at:]
}
// virtualLoginPhoneMinDigits/virtualLoginPhoneMaxDigits bound the "888"-prefixed
// virtual login identity range NormalizePhone accepts without going through
// libphonenumber (real E.164 numbers never start with 888). This is a login
// identity concept only -- distinct from, and independent of, ownership of any
// purchasable collectible-phone asset with the same digit shape.
const (
virtualLoginPhoneMinDigits = 7
virtualLoginPhoneMaxDigits = 15
)
// PhoneDigits removes presentation punctuation from a phone number. It is
// intentionally not an identity canonicalizer: callers that select accounts,
// issue codes, or persist users must use NormalizePhone and ValidPhone.
func PhoneDigits(phone string) string {
var b strings.Builder
b.Grow(len(phone))
seenDigit := false
seenPlus := false
for _, r := range phone {
switch {
case r >= '0' && r <= '9':
b.WriteRune(r)
seenDigit = true
case r == '+':
if seenPlus || seenDigit {
return ""
}
seenPlus = true
case unicode.IsSpace(r), r == '-', r == '(', r == ')', r == '.', r == '/':
// Presentation separators accepted by official clients and contact UIs.
default:
return ""
}
}
return b.String()
}
// NormalizePhone returns the one persisted login identity. Virtual +888
// identities are independent of the collectible-phone registry and accept
// 7-15 canonical digits. Ordinary international numbers use E.164 digits
// without the leading '+'. Their parsing is deliberately country-aware so a
// national trunk prefix is removed only where the numbering plan says it is a
// prefix. For example, both +98 0998 167 9461 and +98 998 167 9461 become
// 989981679461, while Italy's significant leading zero in +39 02 ... is retained.
//
// Email-signup synthetic numbers (EncodeEmailPhone, "888" prefix plus at least
// one letter) are a separate exception, kept as-is (lower+trim only, no digit
// stripping) -- otherwise DecodeEmailPhone could never recover the email from
// an already letter-stripped value. A real phone is always pure digits, so
// this check never misclassifies one.
//
// IsPossibleNumber is the structural gate rather than IsValidNumber. It keeps
// syntactically possible reserved/test ranges usable without accepting local
// numbers that omit their country calling code or numbers outside E.164's
// length/plan metadata.
func NormalizePhone(phone string) string {
if IsEmailSignupPhone(phone) {
return strings.ToLower(strings.TrimSpace(phone))
}
digits := PhoneDigits(phone)
if digits == "" {
return ""
}
// Every syntactically valid +888 virtual number is an independent login
// identity; minting or owning the same collectible-phone value is not a
// prerequisite. users.phone therefore takes lookup precedence over any
// optional collectible alias registry.
if len(digits) >= virtualLoginPhoneMinDigits &&
len(digits) <= virtualLoginPhoneMaxDigits &&
strings.HasPrefix(digits, "888") {
return digits
}
// 42777 is the reserved, non-login phone of the built-in service identity.
// It predates the ordinary E.164 user invariant and remains resolvable only
// so auth can reject it as a system account instead of treating it as free.
if digits == OfficialSystemPhone {
return digits
}
number, err := phonenumbers.Parse("+"+digits, phonenumbers.UNKNOWN_REGION)
if err != nil || !phonenumbers.IsPossibleNumber(number) {
return ""
}
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
if canonical == "" || len(canonical) > 15 {
return ""
}
return canonical
}
// ValidPhone reports whether phone is already in the persisted canonical form.
// Callers accepting user input normalize first, then validate, so equivalent
// international spellings converge before lookup, rate limiting, OTP delivery,
// and uniqueness checks. Email-signup synthetic numbers keep their own
// lower+trim canonical form (see NormalizePhone).
func ValidPhone(phone string) bool {
if IsEmailSignupPhone(phone) {
if len(phone) < 5 || len(phone) > 200 {
return false
}
for _, r := range phone {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
return false
}
}
return true
}
canonical := NormalizePhone(phone)
return canonical != "" && canonical == phone
}