feat: sync multilayer td integration

This commit is contained in:
A 2026-07-15 13:32:06 +08:00
parent 20a310f6ca
commit 766c5db992
491 changed files with 26235 additions and 35340 deletions

View file

@ -13,6 +13,11 @@ var (
// ErrAuthKeyProtocolMetadataConflict 表示同一 cryptographic auth_key_id
// 被尝试改写为另一 key body 或另一 permanent/temp 类型/寿命。
ErrAuthKeyProtocolMetadataConflict = errors.New("auth key protocol metadata conflict")
// ErrAuthKeyNotFound prevents client metadata writes from silently succeeding
// after the protocol key row has already disappeared. auth_keys is the
// authoritative Layer source; an authorization mirror must never advance on
// its own when that primary write did not happen.
ErrAuthKeyNotFound = errors.New("auth key not found")
// ErrAuthKeyNotPermanent 防止 authorization 落到 temporary/legacy-unknown key。
ErrAuthKeyNotPermanent = errors.New("auth key is not permanent")
// ErrAuthKeyBindingInvalid 表示 temp/perm 引用缺失、类型错误,或 binding
@ -38,17 +43,25 @@ type AuthKeyData struct {
// 0 只允许表示 permanent key-1 仅表示 migration 0086 无法证明类型的历史 key
// edge 必须用 -404 拒绝并迫使客户端重握手。key 类型是握手事实,不能由
// authorization 是否存在推断。
ExpiresAt int
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
ExpiresAt int
Layer int
// LayerObservationID globally orders durable explicit Layer observations
// across sessions, processes and restarts. Zero means legacy/no ordered
// evidence; it is still a usable inherited default but cannot outrank a
// positive observation during temp-to-perm identity merge.
LayerObservationID int64
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
// 用户绑定不在此处auth_key 是协议产物授权auth_key↔user + 设备信息)由 authorization 承载P2
}
type AuthKeyClientInfo struct {
// Layer is the durable last-known default. The RPC boundary validates it
// against the generated profile set before use; stores must preserve the
// exact value and must never clamp a future unsupported Layer.
Layer int
DeviceModel string
Platform string
@ -57,13 +70,44 @@ type AuthKeyClientInfo struct {
AppVersion string
}
// MergeAuthKeyLayerObservations resolves the inherited default when a raw
// temporary key is bound to its permanent identity. Positive observation IDs
// are globally ordered durable evidence. Equal positive IDs must describe the
// same Layer; zero is legacy/unordered and therefore defers to the permanent
// identity. Both rows must be written to the returned tuple atomically.
func MergeAuthKeyLayerObservations(
tempLayer int,
tempObservationID int64,
permLayer int,
permObservationID int64,
) (layer int, observationID int64, err error) {
if tempLayer < 0 || permLayer < 0 || tempObservationID < 0 || permObservationID < 0 ||
(tempObservationID > 0 && tempLayer == 0) ||
(permObservationID > 0 && permLayer == 0) {
return 0, 0, ErrAuthKeySessionLayerInvalid
}
switch {
case tempObservationID > permObservationID:
return tempLayer, tempObservationID, nil
case permObservationID > tempObservationID:
return permLayer, permObservationID, nil
case tempObservationID > 0 && tempLayer != permLayer:
return 0, 0, ErrAuthKeySessionLayerConflict
case tempObservationID > 0:
return tempLayer, tempObservationID, nil
default:
return permLayer, 0, nil
}
}
// AuthKeyStore 持久化 auth key。实现见 store/memory测试替身、store/postgres。
type AuthKeyStore interface {
// Save 保存一条 auth key 记录;同 ID 重试只能保持 key body 与协议类型/寿命不变。
Save(ctx context.Context, k AuthKeyData) error
// Get 按 auth_key_id 查询;不存在时 found=false。
Get(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error)
// UpdateClientInfo 合并更新 auth key 的客户端协商元数据。
// UpdateClientInfo 合并更新 auth key 的客户端协商元数据。目标 key 不存在时
// 必须返回 ErrAuthKeyNotFound禁止把缺失 primary 当成成功后继续更新 mirror。
// 空字段不覆盖已有值layer/api_id 为 0 时不覆盖。
UpdateClientInfo(ctx context.Context, id [8]byte, info AuthKeyClientInfo) error
// Delete 删除一条 auth key 记录destroy_auth_key。不存在时静默成功。

View file

@ -0,0 +1,85 @@
package store
import (
"context"
"errors"
"time"
)
var (
// ErrAuthKeySessionLayerInvalid rejects malformed protocol evidence before it
// can enter the durable same-session ordering boundary.
ErrAuthKeySessionLayerInvalid = errors.New("invalid auth key session layer evidence")
// ErrAuthKeySessionLayerConflict means one client msg_id selected two
// different Layers for the same raw auth key and logical MTProto session.
ErrAuthKeySessionLayerConflict = errors.New("conflicting auth key session layer evidence")
)
// AuthKeySessionLayer is the short-lived durable high-water mark for explicit
// invokeWithLayer evidence on one logical MTProto session. It is deliberately
// keyed by the raw (wire) auth key rather than the canonical permanent key:
// two PFS temporary keys may reuse a session_id without becoming one session.
//
// ExpiresAt bounds storage and is not a protocol permission. Once this proof
// expires, a new/naked session uses the auth-key-wide last-known Layer default;
// old client msg_ids outside the MTProto freshness window remain request-local
// and cannot recreate shared state.
type AuthKeySessionLayer struct {
Layer int
MessageID int64
ObservationID int64
ExpiresAt time.Time
// SharedDefault is populated by AdvanceSessionLayer. It is true when this
// session observation is still the current auth-key-wide default after the
// transaction. A duplicate may safely refresh local caches only in that
// case; an older session must not overwrite a newer session's default.
SharedDefault bool
}
// AuthKeySessionLayerStore linearizes explicit Layer evidence across server
// processes and restarts. AdvanceSessionLayer never replaces a live row with a
// lower msg_id. applied is true only for an insert, a strictly newer msg_id, or
// replacement of an expired row; duplicate/older evidence returns the current
// row with applied=false. A successful advance and the auth-key-wide default
// update share one store transaction and one globally ordered ObservationID;
// callers must not persist the default in a second best-effort write.
// AdvanceSessionLayer derives ExpiresAt from a fresh client msg_id at the store
// boundary; no caller-controlled retention duration is accepted.
type AuthKeySessionLayerStore interface {
GetSessionLayer(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64) (value AuthKeySessionLayer, found bool, err error)
AdvanceSessionLayer(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, layer int, msgID int64) (current AuthKeySessionLayer, applied bool, err error)
DeleteSessionLayer(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64) (deleted bool, err error)
DeleteExpiredSessionLayers(ctx context.Context, limit int) (deleted int, err error)
}
// AuthKeySessionLayerExpiry derives the complete mutable lifetime of explicit
// Layer evidence from the client MTProto msg_id which carried it. The caller
// cannot choose a longer retention window: that would let stale selectors keep
// rewriting the auth-key-wide default after their replay-admission authority
// expired. The calculation intentionally mirrors proto.MessageID.Time without
// importing the protocol package into the store boundary.
func AuthKeySessionLayerExpiry(msgID int64) (time.Time, bool) {
if msgID <= 0 || msgID%4 != 0 || uint32(msgID) == 0 {
// MTProto client message ids are 0 modulo 4 and must carry a
// non-empty lower-32-bit fractional component.
return time.Time{}, false
}
createdAt := time.Unix(msgID>>32, int64(int32(msgID))).UTC()
return createdAt.Add(301 * time.Second), true
}
// AuthKeySessionLayerEvidenceFresh applies the MTProto freshness envelope at
// the durable write boundary. Edge admission normally rejects stale/future
// messages first; the store repeats the invariant so an alternate caller can
// neither publish an already-expired selector nor manufacture future state.
func AuthKeySessionLayerEvidenceFresh(now time.Time, msgID int64) (time.Time, bool) {
expiresAt, ok := AuthKeySessionLayerExpiry(msgID)
if !ok || !now.Before(expiresAt) {
return time.Time{}, false
}
createdAt := expiresAt.Add(-301 * time.Second)
if createdAt.After(now.Add(30 * time.Second)) {
return time.Time{}, false
}
return expiresAt, true
}

View file

@ -0,0 +1,72 @@
package store
import (
"errors"
"testing"
"time"
)
func TestMergeAuthKeyLayerObservations(t *testing.T) {
tests := []struct {
name string
tempLayer int
tempID int64
permLayer int
permID int64
wantLayer int
wantID int64
wantErr error
}{
{name: "temporary newer", tempLayer: 227, tempID: 20, permLayer: 220, permID: 10, wantLayer: 227, wantID: 20},
{name: "permanent newer", tempLayer: 220, tempID: 10, permLayer: 227, permID: 20, wantLayer: 227, wantID: 20},
{name: "equal ordered observation", tempLayer: 225, tempID: 30, permLayer: 225, permID: 30, wantLayer: 225, wantID: 30},
{name: "equal ordered conflict", tempLayer: 220, tempID: 30, permLayer: 227, permID: 30, wantErr: ErrAuthKeySessionLayerConflict},
{name: "legacy permanent wins", tempLayer: 220, permLayer: 227, wantLayer: 227},
{name: "legacy permanent zero wins", tempLayer: 220, wantLayer: 0},
{name: "negative observation", tempLayer: 220, tempID: -1, permLayer: 227, wantErr: ErrAuthKeySessionLayerInvalid},
{name: "ordered zero layer", tempLayer: 0, tempID: 1, permLayer: 227, wantErr: ErrAuthKeySessionLayerInvalid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotLayer, gotID, err := MergeAuthKeyLayerObservations(tt.tempLayer, tt.tempID, tt.permLayer, tt.permID)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("merge error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil || gotLayer != tt.wantLayer || gotID != tt.wantID {
t.Fatalf("merge = (%d,%d,%v), want (%d,%d,nil)", gotLayer, gotID, err, tt.wantLayer, tt.wantID)
}
})
}
}
func TestAuthKeySessionLayerExpiryIsOwnedByClientMessageID(t *testing.T) {
const (
seconds = int64(2_000_000_000)
fractional = int64(123_456_788) // non-zero and client-owned (mod 4 == 0)
)
msgID := (seconds << 32) | fractional
got, ok := AuthKeySessionLayerExpiry(msgID)
want := time.Unix(seconds, int64(int32(msgID))).UTC().Add(301 * time.Second)
if !ok || !got.Equal(want) {
t.Fatalf("expiry = (%v,%v), want (%v,true)", got, ok, want)
}
for _, invalid := range []int64{0, -4, seconds << 32, msgID + 1, msgID + 3} {
if expiry, ok := AuthKeySessionLayerExpiry(invalid); ok || !expiry.IsZero() {
t.Fatalf("invalid msg_id %d expiry = (%v,%v)", invalid, expiry, ok)
}
}
now := time.Unix(seconds, 0).UTC()
if expiry, ok := AuthKeySessionLayerEvidenceFresh(now, msgID); !ok || !expiry.Equal(want) {
t.Fatalf("fresh evidence = (%v,%v), want (%v,true)", expiry, ok, want)
}
stale := (now.Add(-302*time.Second).Unix() << 32) | 4
tooFuture := (now.Add(31*time.Second).Unix() << 32) | 4
for _, invalid := range []int64{stale, tooFuture} {
if expiry, ok := AuthKeySessionLayerEvidenceFresh(now, invalid); ok || !expiry.IsZero() {
t.Fatalf("out-of-window msg_id %d evidence = (%v,%v)", invalid, expiry, ok)
}
}
}

View file

@ -10,7 +10,6 @@ import (
type AuthorizationStore interface {
Bind(ctx context.Context, a domain.Authorization) error
ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error)
UpdateLayer(ctx context.Context, authKeyID [8]byte, layer int) error
// UpdateClientInfo 合并更新已绑定授权的客户端元数据,使设备列表与 auth key 协商事实一致。
UpdateClientInfo(ctx context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error
ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error)
@ -20,3 +19,12 @@ type AuthorizationStore interface {
// MarkPasswordPassed 清除 auth_key 的 password_pending 标记,使其转为完全授权(两步验证通过后调用)。
MarkPasswordPassed(ctx context.Context, authKeyID [8]byte) error
}
// AuthKeyAuthorityLinker is an optional in-process store-composition boundary.
// Implementations whose auth_keys primary and authorization projection live in
// separate in-memory objects can attach them here so Layer writes update both
// under one state transition. Durable stores normally enforce this inside one
// database transaction and do not implement the hook.
type AuthKeyAuthorityLinker interface {
LinkAuthKeyAuthority(AuthKeyStore)
}

View file

@ -11,9 +11,12 @@ import (
)
type authKeyState struct {
mu sync.RWMutex
keys map[[8]byte]store.AuthKeyData
bindings map[[8]byte]domain.TempAuthKeyBinding
mu sync.RWMutex
keys map[[8]byte]store.AuthKeyData
bindings map[[8]byte]domain.TempAuthKeyBinding
sessionLayers map[authKeySessionLayerKey]store.AuthKeySessionLayer
authorizationMirrors map[*AuthorizationStore]struct{}
nextLayerObservation int64
}
// AuthKeyStore 是 store.AuthKeyStore 的内存实现。
@ -24,8 +27,10 @@ type AuthKeyStore struct {
// NewAuthKeyStore 创建内存 AuthKeyStore。
func NewAuthKeyStore() *AuthKeyStore {
return &AuthKeyStore{state: &authKeyState{
keys: make(map[[8]byte]store.AuthKeyData),
bindings: make(map[[8]byte]domain.TempAuthKeyBinding),
keys: make(map[[8]byte]store.AuthKeyData),
bindings: make(map[[8]byte]domain.TempAuthKeyBinding),
sessionLayers: make(map[authKeySessionLayerKey]store.AuthKeySessionLayer),
authorizationMirrors: make(map[*AuthorizationStore]struct{}),
}}
}
@ -34,11 +39,35 @@ func (s *AuthKeyStore) Save(_ context.Context, k store.AuthKeyData) error {
return store.ErrInvalidAuthKeyProtocolExpiry
}
s.state.mu.Lock()
if current, ok := s.state.keys[k.ID]; ok && (current.Value != k.Value || current.ExpiresAt != k.ExpiresAt) {
s.state.mu.Unlock()
return store.ErrAuthKeyProtocolMetadataConflict
if current, ok := s.state.keys[k.ID]; ok {
if current.Value != k.Value || current.ExpiresAt != k.ExpiresAt {
s.state.mu.Unlock()
return store.ErrAuthKeyProtocolMetadataConflict
}
// Save is the idempotent protocol-key upsert. Match PostgreSQL's
// ON CONFLICT behavior: a repeated handshake may refresh server_salt,
// but must never erase Layer/client metadata recorded after the first
// insert merely because the protocol write carries zero-value metadata.
if k.CreatedAt == 0 {
k.CreatedAt = current.CreatedAt
}
k.Layer = current.Layer
k.LayerObservationID = current.LayerObservationID
k.DeviceModel = current.DeviceModel
k.Platform = current.Platform
k.SystemVersion = current.SystemVersion
k.APIID = current.APIID
k.AppVersion = current.AppVersion
}
s.state.keys[k.ID] = k
s.state.mirrorAuthorizationLayersLocked([][8]byte{k.ID}, k.Layer)
// Tests may restore a store snapshot carrying an already-issued durable
// observation. Keep the in-memory sequence above that watermark so the next
// AdvanceSessionLayer has the same monotonic ordering as PostgreSQL's
// sequence after a restart/fixture restore.
if k.LayerObservationID > s.state.nextLayerObservation {
s.state.nextLayerObservation = k.LayerObservationID
}
s.state.mu.Unlock()
return nil
}
@ -53,10 +82,17 @@ func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bo
func (s *AuthKeyStore) UpdateClientInfo(_ context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
s.state.mu.Lock()
k, ok := s.state.keys[id]
if ok {
mergeAuthKeyClientInfo(&k, info)
s.state.keys[id] = k
if !ok {
s.state.mu.Unlock()
return store.ErrAuthKeyNotFound
}
if info.Layer > 0 && k.LayerObservationID > 0 && info.Layer != k.Layer {
s.state.mu.Unlock()
return store.ErrAuthKeySessionLayerConflict
}
mergeAuthKeyClientInfo(&k, info)
s.state.keys[id] = k
s.state.mirrorAuthorizationLayersLocked([][8]byte{id}, k.Layer)
s.state.mu.Unlock()
return nil
}
@ -82,28 +118,71 @@ func mergeAuthKeyClientInfo(k *store.AuthKeyData, info store.AuthKeyClientInfo)
}
}
// mirrorAuthorizationLayersLocked updates the materialized authorization view
// at the same write boundary as auth_keys. authKeyState.mu must be held; the
// only cross-object lock order is auth-key state -> authorization mirror.
func (s *authKeyState) mirrorAuthorizationLayersLocked(ids [][8]byte, layer int) {
for mirror := range s.authorizationMirrors {
mirror.mu.Lock()
for _, id := range ids {
if a, found := mirror.m[id]; found {
a.Layer = layer
mirror.m[id] = a
}
}
mirror.mu.Unlock()
}
}
func (s *authKeyState) deleteAuthorizationMirrorsLocked(ids [][8]byte) {
s.deleteAuthorizationMirrorsWithHeldLocked(ids, nil)
}
func (s *authKeyState) deleteAuthorizationMirrorsWithHeldLocked(ids [][8]byte, held *AuthorizationStore) {
for mirror := range s.authorizationMirrors {
if mirror != held {
mirror.mu.Lock()
}
for _, id := range ids {
delete(mirror.m, id)
}
if mirror != held {
mirror.mu.Unlock()
}
}
}
func (s *AuthKeyStore) Delete(_ context.Context, id [8]byte) error {
s.state.mu.Lock()
deleting, exists := s.state.keys[id]
deleted := s.state.deleteProtocolAuthKeyLocked(id)
s.state.deleteAuthorizationMirrorsLocked(deleted)
s.state.mu.Unlock()
return nil
}
func (s *authKeyState) deleteProtocolAuthKeyLocked(id [8]byte) [][8]byte {
deleting, exists := s.keys[id]
if !exists {
s.state.mu.Unlock()
return nil
}
deleted := make([][8]byte, 0, 2)
if deleting.ExpiresAt > 0 {
delete(s.state.bindings, id)
delete(s.bindings, id)
} else {
permID := int64(binary.LittleEndian.Uint64(id[:]))
for tempID, binding := range s.state.bindings {
for tempID, binding := range s.bindings {
if binding.PermAuthKeyID != permID {
continue
}
delete(s.state.bindings, tempID)
delete(s.state.keys, tempID)
delete(s.bindings, tempID)
delete(s.keys, tempID)
s.deleteSessionLayersLocked(tempID)
deleted = append(deleted, tempID)
}
}
delete(s.state.keys, id)
s.state.mu.Unlock()
return nil
delete(s.keys, id)
s.deleteSessionLayersLocked(id)
return append(deleted, id)
}
// TempAuthKeyBindingStore 是 store.TempAuthKeyBindingStore 的内存实现。
@ -133,7 +212,22 @@ func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBi
if !tempFound || !permFound || temp.ExpiresAt <= 0 || perm.ExpiresAt != 0 || b.ExpiresAt != temp.ExpiresAt {
return store.ErrAuthKeyBindingInvalid
}
// Binding and Layer-default normalization are one state transition. Exact
// session evidence remains keyed by the raw temp key; only the inherited
// default follows the globally ordered observation.
layer, observationID, err := store.MergeAuthKeyLayerObservations(
temp.Layer, temp.LayerObservationID,
perm.Layer, perm.LayerObservationID,
)
if err != nil {
return err
}
temp.Layer, temp.LayerObservationID = layer, observationID
perm.Layer, perm.LayerObservationID = layer, observationID
s.state.keys[b.TempAuthKeyID] = temp
s.state.keys[permID] = perm
s.state.bindings[b.TempAuthKeyID] = b
s.state.mirrorAuthorizationLayersLocked([][8]byte{b.TempAuthKeyID, permID}, layer)
return nil
}
@ -164,6 +258,8 @@ func (s *TempAuthKeyBindingStore) DeleteExpired(_ context.Context, expiredBefore
}
delete(s.state.bindings, id)
delete(s.state.keys, id)
s.state.deleteSessionLayersLocked(id)
s.state.deleteAuthorizationMirrorsLocked([][8]byte{id})
deleted++
}
return deleted, nil
@ -171,8 +267,10 @@ func (s *TempAuthKeyBindingStore) DeleteExpired(_ context.Context, expiredBefore
// AuthorizationStore 是 store.AuthorizationStore 的内存实现。
type AuthorizationStore struct {
mu sync.RWMutex
m map[[8]byte]domain.Authorization
linkMu sync.RWMutex
authKeys *authKeyState
mu sync.RWMutex
m map[[8]byte]domain.Authorization
}
// NewAuthorizationStore 创建内存 AuthorizationStore。
@ -180,6 +278,42 @@ func NewAuthorizationStore() *AuthorizationStore {
return &AuthorizationStore{m: make(map[[8]byte]domain.Authorization)}
}
// LinkAuthKeyAuthority connects the test/dev in-memory projection to the same
// auth-key state. PostgreSQL performs the equivalent projection updates inside
// its write transactions. Existing standalone AuthorizationStore construction
// remains valid for tests that intentionally omit protocol keys.
func (s *AuthorizationStore) LinkAuthKeyAuthority(keys store.AuthKeyStore) {
authKeys, ok := keys.(*AuthKeyStore)
if !ok || authKeys == nil || authKeys.state == nil {
return
}
s.linkMu.Lock()
defer s.linkMu.Unlock()
if s.authKeys == authKeys.state {
return
}
if s.authKeys != nil {
// A projection has one authoritative primary. Constructors do not
// return errors, so preserve the first explicit composition.
return
}
authKeys.state.mu.Lock()
s.mu.Lock()
for id, a := range s.m {
if key, found := authKeys.state.keys[id]; found {
a.Layer = key.Layer
s.m[id] = a
}
}
if authKeys.state.authorizationMirrors == nil {
authKeys.state.authorizationMirrors = make(map[*AuthorizationStore]struct{})
}
authKeys.state.authorizationMirrors[s] = struct{}{}
s.authKeys = authKeys.state
s.mu.Unlock()
authKeys.state.mu.Unlock()
}
func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) error {
now := time.Now()
if a.Hash == 0 {
@ -189,13 +323,40 @@ func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) err
a.CreatedAt = now
}
a.ActiveAt = now
s.linkMu.RLock()
if s.authKeys != nil {
s.authKeys.mu.RLock()
key, found := s.authKeys.keys[a.AuthKeyID]
if !found {
s.authKeys.mu.RUnlock()
s.linkMu.RUnlock()
return store.ErrAuthKeyNotFound
}
if key.ExpiresAt != 0 {
s.authKeys.mu.RUnlock()
s.linkMu.RUnlock()
return store.ErrAuthKeyNotPermanent
}
a.Layer = key.Layer
s.mu.Lock()
s.bindLocked(a)
s.mu.Unlock()
s.authKeys.mu.RUnlock()
s.linkMu.RUnlock()
return nil
}
s.linkMu.RUnlock()
s.mu.Lock()
s.bindLocked(a)
s.mu.Unlock()
return nil
}
func (s *AuthorizationStore) bindLocked(a domain.Authorization) {
if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() {
a.CreatedAt = existing.CreatedAt
}
s.m[a.AuthKeyID] = a
s.mu.Unlock()
return nil
}
func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Authorization, bool, error) {
@ -205,46 +366,57 @@ func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Au
return a, ok, nil
}
func (s *AuthorizationStore) UpdateLayer(_ context.Context, id [8]byte, layer int) error {
if layer <= 0 {
func (s *AuthorizationStore) UpdateClientInfo(_ context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
s.linkMu.RLock()
if s.authKeys != nil {
s.authKeys.mu.RLock()
key, found := s.authKeys.keys[id]
if !found {
s.authKeys.mu.RUnlock()
s.linkMu.RUnlock()
return store.ErrAuthKeyNotFound
}
s.mu.Lock()
if a, ok := s.m[id]; ok {
mergeAuthorizationClientInfo(&a, info)
a.Layer = key.Layer
s.m[id] = a
}
s.mu.Unlock()
s.authKeys.mu.RUnlock()
s.linkMu.RUnlock()
return nil
}
s.linkMu.RUnlock()
s.mu.Lock()
if a, ok := s.m[id]; ok {
a.Layer = layer
a.ActiveAt = time.Now()
mergeAuthorizationClientInfo(&a, info)
s.m[id] = a
}
s.mu.Unlock()
return nil
}
func (s *AuthorizationStore) UpdateClientInfo(_ context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
s.mu.Lock()
if a, ok := s.m[id]; ok {
if info.Layer > 0 {
a.Layer = info.Layer
}
if info.DeviceModel != "" {
a.DeviceModel = info.DeviceModel
}
if info.Platform != "" {
a.Platform = info.Platform
}
if info.SystemVersion != "" {
a.SystemVersion = info.SystemVersion
}
if info.APIID != 0 {
a.APIID = info.APIID
}
if info.AppVersion != "" {
a.AppVersion = info.AppVersion
}
a.ActiveAt = time.Now()
s.m[id] = a
func mergeAuthorizationClientInfo(a *domain.Authorization, info domain.AuthKeyClientInfo) {
if info.Layer > 0 {
a.Layer = info.Layer
}
s.mu.Unlock()
return nil
if info.DeviceModel != "" {
a.DeviceModel = info.DeviceModel
}
if info.Platform != "" {
a.Platform = info.Platform
}
if info.SystemVersion != "" {
a.SystemVersion = info.SystemVersion
}
if info.APIID != 0 {
a.APIID = info.APIID
}
if info.AppVersion != "" {
a.AppVersion = info.AppVersion
}
a.ActiveAt = time.Now()
}
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
@ -289,6 +461,39 @@ func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64)
return domain.Authorization{}, false, nil
}
// RevokeByHash mirrors PostgreSQL's protocol-key revocation boundary when this
// authorization projection is linked to an in-memory auth-key authority.
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
s.linkMu.RLock()
defer s.linkMu.RUnlock()
if s.authKeys == nil {
return s.DeleteByHash(ctx, userID, hash)
}
s.authKeys.mu.Lock()
s.mu.Lock()
var (
targetID [8]byte
target domain.Authorization
found bool
)
for id, a := range s.m {
if a.UserID == userID && a.Hash == hash {
targetID, target, found = id, a, true
break
}
}
if !found {
s.mu.Unlock()
s.authKeys.mu.Unlock()
return domain.Authorization{}, false, nil
}
deletedIDs := s.authKeys.deleteProtocolAuthKeyLocked(targetID)
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return target, true, nil
}
func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -303,6 +508,32 @@ func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64,
return out, nil
}
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
s.linkMu.RLock()
defer s.linkMu.RUnlock()
if s.authKeys == nil {
return s.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
}
s.authKeys.mu.Lock()
s.mu.Lock()
out := make([]domain.Authorization, 0)
targets := make([][8]byte, 0)
for id, a := range s.m {
if a.UserID == userID && id != keepAuthKeyID {
out = append(out, a)
targets = append(targets, id)
}
}
deletedIDs := make([][8]byte, 0, len(targets))
for _, id := range targets {
deletedIDs = append(deletedIDs, s.authKeys.deleteProtocolAuthKeyLocked(id)...)
}
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return out, nil
}
// CodeStore 是 store.CodeStore 的内存实现(带 TTL
type CodeStore struct {
mu sync.Mutex

View file

@ -44,6 +44,178 @@ func TestAuthKeyStorePreservesProtocolExpiry(t *testing.T) {
}
}
func TestAuthKeyStoreProtocolRetryPreservesClientLayerMetadata(t *testing.T) {
ctx := context.Background()
keys := NewAuthKeyStore()
id := memoryAuthKeyID(17)
key := store.AuthKeyData{ID: id, ServerSalt: 10, CreatedAt: 11}
key.Value[0] = 1
if err := keys.Save(ctx, key); err != nil {
t.Fatalf("save auth key: %v", err)
}
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Layer: 227, DeviceModel: "Desktop", Platform: "tdesktop",
SystemVersion: "Windows", APIID: 2040, AppVersion: "6.2",
}); err != nil {
t.Fatalf("update client info: %v", err)
}
retry := key
retry.ServerSalt = 20
retry.CreatedAt = 0
if err := keys.Save(ctx, retry); err != nil {
t.Fatalf("retry protocol save: %v", err)
}
got, found, err := keys.Get(ctx, id)
if err != nil || !found {
t.Fatalf("get auth key: found=%v err=%v", found, err)
}
if got.ServerSalt != 20 || got.CreatedAt != 11 {
t.Fatalf("protocol fields = salt:%d created:%d, want 20/11", got.ServerSalt, got.CreatedAt)
}
if got.Layer != 227 || got.DeviceModel != "Desktop" || got.Platform != "tdesktop" ||
got.SystemVersion != "Windows" || got.APIID != 2040 || got.AppVersion != "6.2" {
t.Fatalf("client metadata was erased by protocol retry: %+v", got)
}
}
func TestAuthKeyStoreUpdateClientInfoRejectsMissingPrimary(t *testing.T) {
keys := NewAuthKeyStore()
err := keys.UpdateClientInfo(context.Background(), memoryAuthKeyID(18), store.AuthKeyClientInfo{Layer: 227})
if !errors.Is(err, store.ErrAuthKeyNotFound) {
t.Fatalf("missing primary update error = %v, want %v", err, store.ErrAuthKeyNotFound)
}
}
func TestAuthKeyStoreUpdateClientInfoProtectsObservedLayer(t *testing.T) {
ctx := context.Background()
keys := NewAuthKeyStore()
id := memoryAuthKeyID(19)
want := store.AuthKeyData{
ID: id, Layer: 227, LayerObservationID: 91,
DeviceModel: "before", Platform: "tdesktop",
}
if err := keys.Save(ctx, want); err != nil {
t.Fatalf("save observed auth key: %v", err)
}
err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Layer: 220, DeviceModel: "must-not-merge", AppVersion: "must-not-merge",
})
if !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
t.Fatalf("conflicting layer update error = %v, want %v", err, store.ErrAuthKeySessionLayerConflict)
}
got, found, err := keys.Get(ctx, id)
if err != nil || !found || got != want {
t.Fatalf("auth key changed after layer conflict: got=%+v found=%v err=%v, want=%+v", got, found, err, want)
}
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Layer: 227, DeviceModel: "same-layer", AppVersion: "1.0",
}); err != nil {
t.Fatalf("same observed layer metadata merge: %v", err)
}
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Layer: 0, Platform: "windows", SystemVersion: "11",
}); err != nil {
t.Fatalf("layerless metadata merge: %v", err)
}
got, found, err = keys.Get(ctx, id)
if err != nil || !found {
t.Fatalf("get merged auth key: found=%v err=%v", found, err)
}
if got.Layer != 227 || got.LayerObservationID != 91 || got.DeviceModel != "same-layer" ||
got.Platform != "windows" || got.SystemVersion != "11" || got.AppVersion != "1.0" {
t.Fatalf("guarded metadata merge = %+v", got)
}
}
func TestTempAuthKeyBindingStoreMergesLayerObservations(t *testing.T) {
const handshakeExpiry = 1_800_000_000
tests := []struct {
name string
tempLayer int
tempObs int64
permLayer int
permObs int64
wantLayer int
wantObs int64
wantErr error
}{
{name: "temporary newer", tempLayer: 227, tempObs: 20, permLayer: 220, permObs: 10, wantLayer: 227, wantObs: 20},
{name: "permanent newer", tempLayer: 220, tempObs: 10, permLayer: 227, permObs: 20, wantLayer: 227, wantObs: 20},
{name: "equal ordered same layer", tempLayer: 225, tempObs: 30, permLayer: 225, permObs: 30, wantLayer: 225, wantObs: 30},
{name: "equal ordered conflict", tempLayer: 220, tempObs: 30, permLayer: 227, permObs: 30, wantErr: store.ErrAuthKeySessionLayerConflict},
{name: "legacy permanent wins", tempLayer: 220, permLayer: 227, wantLayer: 227},
}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
keys := NewAuthKeyStore()
bindings := NewTempAuthKeyBindingStore(keys)
tempID := memoryAuthKeyID(int64(1_000 + i*2))
permID := memoryAuthKeyID(int64(1_001 + i*2))
tempBefore := store.AuthKeyData{
ID: tempID, ExpiresAt: handshakeExpiry,
Layer: tt.tempLayer, LayerObservationID: tt.tempObs, DeviceModel: "temp",
}
permBefore := store.AuthKeyData{
ID: permID, Layer: tt.permLayer, LayerObservationID: tt.permObs, DeviceModel: "perm",
}
if err := keys.Save(ctx, tempBefore); err != nil {
t.Fatalf("save temporary auth key: %v", err)
}
if err := keys.Save(ctx, permBefore); err != nil {
t.Fatalf("save permanent auth key: %v", err)
}
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: tempID,
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permID[:])),
ExpiresAt: handshakeExpiry,
}
err := bindings.Save(ctx, binding)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("bind error = %v, want %v", err, tt.wantErr)
}
tempAfter, _, _ := keys.Get(ctx, tempID)
permAfter, _, _ := keys.Get(ctx, permID)
if tempAfter != tempBefore || permAfter != permBefore {
t.Fatalf("conflicting bind changed keys: temp=%+v perm=%+v", tempAfter, permAfter)
}
if _, found, getErr := bindings.GetByTemp(ctx, tempID); getErr != nil || found {
t.Fatalf("conflicting binding found=%v err=%v, want absent", found, getErr)
}
return
}
if err != nil {
t.Fatalf("bind: %v", err)
}
tempAfter, tempFound, getErr := keys.Get(ctx, tempID)
if getErr != nil || !tempFound {
t.Fatalf("get temporary after bind: found=%v err=%v", tempFound, getErr)
}
permAfter, permFound, getErr := keys.Get(ctx, permID)
if getErr != nil || !permFound {
t.Fatalf("get permanent after bind: found=%v err=%v", permFound, getErr)
}
if tempAfter.Layer != tt.wantLayer || tempAfter.LayerObservationID != tt.wantObs ||
permAfter.Layer != tt.wantLayer || permAfter.LayerObservationID != tt.wantObs {
t.Fatalf("merged defaults: temp=(%d,%d) perm=(%d,%d), want=(%d,%d)",
tempAfter.Layer, tempAfter.LayerObservationID,
permAfter.Layer, permAfter.LayerObservationID,
tt.wantLayer, tt.wantObs)
}
if tempAfter.DeviceModel != "temp" || permAfter.DeviceModel != "perm" {
t.Fatalf("bind erased client metadata: temp=%+v perm=%+v", tempAfter, permAfter)
}
if _, found, getErr := bindings.GetByTemp(ctx, tempID); getErr != nil || !found {
t.Fatalf("merged binding found=%v err=%v, want present", found, getErr)
}
})
}
}
func TestTempAuthKeyBindingStoreIsIdempotentAndRejectsCrossPermanentRebind(t *testing.T) {
ctx := context.Background()
keys := NewAuthKeyStore()

View file

@ -0,0 +1,163 @@
package memory
import (
"context"
"encoding/binary"
"math"
"time"
"telesrv/internal/store"
)
type authKeySessionLayerKey struct {
rawAuthKeyID [8]byte
sessionID int64
}
func (s *AuthKeyStore) GetSessionLayer(
_ context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
) (store.AuthKeySessionLayer, bool, error) {
now := time.Now()
key := authKeySessionLayerKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
s.state.mu.Lock()
value, found := s.state.sessionLayers[key]
if found && !now.Before(value.ExpiresAt) {
delete(s.state.sessionLayers, key)
found = false
value = store.AuthKeySessionLayer{}
} else if found {
value.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, value)
}
s.state.mu.Unlock()
return value, found, nil
}
func (s *AuthKeyStore) AdvanceSessionLayer(
_ context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
layer int,
msgID int64,
) (store.AuthKeySessionLayer, bool, error) {
now := time.Now()
expiresAt, freshEvidence := store.AuthKeySessionLayerEvidenceFresh(now, msgID)
if layer <= 0 || !freshEvidence {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
}
key := authKeySessionLayerKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
s.state.mu.Lock()
defer s.state.mu.Unlock()
if _, found := s.state.keys[rawAuthKeyID]; !found {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyNotFound
}
current, found := s.state.sessionLayers[key]
if found && now.Before(current.ExpiresAt) {
switch {
case msgID < current.MessageID:
current.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, current)
return current, false, nil
case msgID == current.MessageID:
if layer != current.Layer {
return current, false, store.ErrAuthKeySessionLayerConflict
}
current.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, current)
return current, false, nil
}
}
var (
permID [8]byte
perm store.AuthKeyData
bound bool
)
if binding, ok := s.state.bindings[rawAuthKeyID]; ok {
bound = true
binary.LittleEndian.PutUint64(permID[:], uint64(binding.PermAuthKeyID))
var permFound bool
perm, permFound = s.state.keys[permID]
if !permFound {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyBindingInvalid
}
}
if s.state.nextLayerObservation == math.MaxInt64 {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
}
s.state.nextLayerObservation++
current = store.AuthKeySessionLayer{
Layer: layer,
MessageID: msgID,
ObservationID: s.state.nextLayerObservation,
ExpiresAt: expiresAt,
}
stored := current
stored.SharedDefault = false
s.state.sessionLayers[key] = stored
raw := s.state.keys[rawAuthKeyID]
raw.Layer = layer
raw.LayerObservationID = current.ObservationID
s.state.keys[rawAuthKeyID] = raw
if bound {
perm.Layer = layer
perm.LayerObservationID = current.ObservationID
s.state.keys[permID] = perm
s.state.mirrorAuthorizationLayersLocked([][8]byte{rawAuthKeyID, permID}, layer)
} else {
s.state.mirrorAuthorizationLayersLocked([][8]byte{rawAuthKeyID}, layer)
}
current.SharedDefault = true
return current, true, nil
}
func (s *AuthKeyStore) DeleteSessionLayer(
_ context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
) (bool, error) {
key := authKeySessionLayerKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
s.state.mu.Lock()
_, deleted := s.state.sessionLayers[key]
delete(s.state.sessionLayers, key)
s.state.mu.Unlock()
return deleted, nil
}
func (s *AuthKeyStore) DeleteExpiredSessionLayers(_ context.Context, limit int) (int, error) {
if limit <= 0 {
return 0, nil
}
now := time.Now()
s.state.mu.Lock()
deleted := 0
for key, value := range s.state.sessionLayers {
if deleted >= limit {
break
}
if now.Before(value.ExpiresAt) {
continue
}
delete(s.state.sessionLayers, key)
deleted++
}
s.state.mu.Unlock()
return deleted, nil
}
func (s *authKeyState) deleteSessionLayersLocked(rawAuthKeyID [8]byte) {
for key := range s.sessionLayers {
if key.rawAuthKeyID == rawAuthKeyID {
delete(s.sessionLayers, key)
}
}
}
func (s *authKeyState) sessionLayerIsSharedDefaultLocked(rawAuthKeyID [8]byte, value store.AuthKeySessionLayer) bool {
defaultKeyID := rawAuthKeyID
if binding, ok := s.bindings[rawAuthKeyID]; ok {
binary.LittleEndian.PutUint64(defaultKeyID[:], uint64(binding.PermAuthKeyID))
}
key, found := s.keys[defaultKeyID]
return found &&
key.Layer == value.Layer &&
key.LayerObservationID == value.ObservationID
}

View file

@ -0,0 +1,235 @@
package memory
import (
"context"
"encoding/binary"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAuthKeySessionLayerOrdersRestartEvidenceAndBindingDefaults(t *testing.T) {
ctx := context.Background()
keys := NewAuthKeyStore()
bindings := NewTempAuthKeyBindingStore(keys)
temp := [8]byte{1}
perm := [8]byte{2}
const tempExpiry = 2_000_000_000
if err := keys.Save(ctx, store.AuthKeyData{ID: temp, ExpiresAt: tempExpiry}); err != nil {
t.Fatal(err)
}
if err := keys.Save(ctx, store.AuthKeyData{ID: perm, ExpiresAt: 0}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
otherMsgID := authKeySessionLayerTestMsgID(now, 3)
first, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, firstMsgID)
if err != nil || !applied || !first.SharedDefault || first.ObservationID <= 0 {
t.Fatalf("first advance = (%+v,%v,%v)", first, applied, err)
}
var permInt [8]byte
copy(permInt[:], perm[:])
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permInt[:])),
ExpiresAt: tempExpiry,
}); err != nil {
t.Fatal(err)
}
for _, id := range [][8]byte{temp, perm} {
got, found, err := keys.Get(ctx, id)
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
}
}
newer, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 227, newerMsgID)
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
t.Fatalf("newer advance = (%+v,%v,%v)", newer, applied, err)
}
older, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, firstMsgID)
if err != nil || applied || older.Layer != 227 || older.MessageID != newerMsgID || !older.SharedDefault {
t.Fatalf("older replay = (%+v,%v,%v)", older, applied, err)
}
if _, _, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, newerMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
t.Fatalf("same-msg conflict = %v", err)
}
other, applied, err := keys.AdvanceSessionLayer(ctx, temp, 11, 225, otherMsgID)
if err != nil || !applied || !other.SharedDefault || other.ObservationID <= newer.ObservationID {
t.Fatalf("other-session advance = (%+v,%v,%v)", other, applied, err)
}
oldSession, found, err := keys.GetSessionLayer(ctx, temp, 10)
if err != nil || !found || oldSession.Layer != 227 || oldSession.SharedDefault {
t.Fatalf("old exact session after other default = (%+v,%v,%v)", oldSession, found, err)
}
for _, id := range [][8]byte{temp, perm} {
got, _, _ := keys.Get(ctx, id)
if got.Layer != 225 || got.LayerObservationID != other.ObservationID {
t.Fatalf("shared default %x = layer %d observation %d", id, got.Layer, got.LayerObservationID)
}
}
}
func TestAuthKeySessionLayerExpiryDeleteAndAuthKeyCascade(t *testing.T) {
ctx := context.Background()
keys := NewAuthKeyStore()
id := [8]byte{3}
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
seedMsgID := authKeySessionLayerTestMsgID(now, 1)
replacementMsgID := authKeySessionLayerTestMsgID(now.Add(time.Second), 1)
otherMsgID := authKeySessionLayerTestMsgID(now.Add(time.Second), 2)
if _, _, err := keys.AdvanceSessionLayer(
ctx, id, 20, 227, authKeySessionLayerTestMsgID(now.Add(-302*time.Second), 1),
); !errors.Is(err, store.ErrAuthKeySessionLayerInvalid) {
t.Fatalf("stale evidence err = %v", err)
}
if _, applied, err := keys.AdvanceSessionLayer(ctx, id, 20, 227, seedMsgID); err != nil || !applied {
t.Fatalf("seed = applied %v err %v", applied, err)
}
// Model the same once-valid row after wall-clock retention elapsed. Tests do
// not pass an arbitrary expiry through the production write API.
key := authKeySessionLayerKey{rawAuthKeyID: id, sessionID: 20}
keys.state.mu.Lock()
expired := keys.state.sessionLayers[key]
expired.ExpiresAt = time.Now().Add(-time.Second)
keys.state.sessionLayers[key] = expired
keys.state.mu.Unlock()
if _, found, err := keys.GetSessionLayer(ctx, id, 20); err != nil || found {
t.Fatalf("expired lookup = found %v err %v", found, err)
}
current, applied, err := keys.AdvanceSessionLayer(ctx, id, 20, 220, replacementMsgID)
if err != nil || !applied || current.Layer != 220 {
t.Fatalf("expired replacement = (%+v,%v,%v)", current, applied, err)
}
if deleted, err := keys.DeleteSessionLayer(ctx, id, 20); err != nil || !deleted {
t.Fatalf("delete session layer = (%v,%v)", deleted, err)
}
if _, applied, err := keys.AdvanceSessionLayer(ctx, id, 21, 227, otherMsgID); err != nil || !applied {
t.Fatalf("cascade seed = applied %v err %v", applied, err)
}
if err := keys.Delete(ctx, id); err != nil {
t.Fatal(err)
}
if _, found, err := keys.GetSessionLayer(ctx, id, 21); err != nil || found {
t.Fatalf("auth key cascade = found %v err %v", found, err)
}
}
func TestAuthKeySessionLayerObservationContinuesAfterRestoredAuthKeyWatermark(t *testing.T) {
ctx := context.Background()
keys := NewAuthKeyStore()
id := [8]byte{4}
if err := keys.Save(ctx, store.AuthKeyData{
ID: id, Layer: 225, LayerObservationID: 91,
}); err != nil {
t.Fatal(err)
}
current, applied, err := keys.AdvanceSessionLayer(
ctx, id, 30, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
)
if err != nil || !applied {
t.Fatalf("advance restored watermark = (%+v,%v,%v)", current, applied, err)
}
if current.ObservationID <= 91 {
t.Fatalf("new observation = %d, want > 91", current.ObservationID)
}
}
func TestAuthKeyLayerAuthorityKeepsAuthorizationProjectionInParity(t *testing.T) {
ctx := context.Background()
t.Run("advance before stale bind", func(t *testing.T) {
keys := NewAuthKeyStore()
auths := NewAuthorizationStore()
auths.LinkAuthKeyAuthority(keys)
perm := memoryAuthKeyID(8_701)
if err := keys.Save(ctx, store.AuthKeyData{ID: perm}); err != nil {
t.Fatal(err)
}
advanced, applied, err := keys.AdvanceSessionLayer(
ctx, perm, 1, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
)
if err != nil || !applied {
t.Fatalf("advance = (%+v,%v,%v)", advanced, applied, err)
}
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: perm, UserID: 1, Layer: 220,
}); err != nil {
t.Fatal(err)
}
got, found, err := auths.ByAuthKey(ctx, perm)
if err != nil || !found || got.Layer != 227 {
t.Fatalf("authorization after stale bind = (%+v,%v,%v)", got, found, err)
}
})
t.Run("bind before advance", func(t *testing.T) {
keys := NewAuthKeyStore()
auths := NewAuthorizationStore()
auths.LinkAuthKeyAuthority(keys)
perm := memoryAuthKeyID(8_702)
if err := keys.Save(ctx, store.AuthKeyData{ID: perm, Layer: 220}); err != nil {
t.Fatal(err)
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: perm, UserID: 2, Layer: 225}); err != nil {
t.Fatal(err)
}
if _, _, err := keys.AdvanceSessionLayer(
ctx, perm, 2, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 2),
); err != nil {
t.Fatal(err)
}
got, found, err := auths.ByAuthKey(ctx, perm)
if err != nil || !found || got.Layer != 227 {
t.Fatalf("authorization after advance = (%+v,%v,%v)", got, found, err)
}
})
t.Run("temp merge", func(t *testing.T) {
keys := NewAuthKeyStore()
auths := NewAuthorizationStore()
auths.LinkAuthKeyAuthority(keys)
bindings := NewTempAuthKeyBindingStore(keys)
temp := memoryAuthKeyID(8_703)
perm := memoryAuthKeyID(8_704)
const expiresAt = 2_000_000_000
if err := keys.Save(ctx, store.AuthKeyData{
ID: temp, ExpiresAt: expiresAt, Layer: 225, LayerObservationID: 20,
}); err != nil {
t.Fatal(err)
}
if err := keys.Save(ctx, store.AuthKeyData{
ID: perm, Layer: 220, LayerObservationID: 10,
}); err != nil {
t.Fatal(err)
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: perm, UserID: 3, Layer: 227}); err != nil {
t.Fatal(err)
}
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: int64(binary.LittleEndian.Uint64(perm[:])),
ExpiresAt: expiresAt,
}); err != nil {
t.Fatal(err)
}
got, found, err := auths.ByAuthKey(ctx, perm)
if err != nil || !found || got.Layer != 225 {
t.Fatalf("authorization after temp merge = (%+v,%v,%v)", got, found, err)
}
})
}
func authKeySessionLayerTestMsgID(at time.Time, order uint32) int64 {
return int64((uint64(at.Unix()) << 32) | uint64(order)<<2)
}

View file

@ -0,0 +1,65 @@
package memory
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestUpdateStateDeliveredCommitSeparatesConfirmedAndObserved(t *testing.T) {
ctx := context.Background()
store := NewUpdateStateStore()
authKeyID := [8]byte{1}
const userID int64 = 1001
if err := store.ObserveClientState(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 20}); err != nil {
t.Fatalf("observe request: %v", err)
}
if _, found, err := store.Get(ctx, authKeyID, userID); err != nil || found {
t.Fatalf("observed request fabricated confirmed state: found=%v err=%v", found, err)
}
if err := store.CommitDeliveredState(ctx, authKeyID, userID, domain.UpdateState{Pts: 5, Date: 50}, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("commit delivered: %v", err)
}
confirmed, found, err := store.Get(ctx, authKeyID, userID)
if err != nil || !found || confirmed.Pts != 5 {
t.Fatalf("confirmed = %+v/%v err=%v, want pts=5", confirmed, found, err)
}
observed, found := store.ObservedClientState(authKeyID, userID)
if !found || observed.Pts != 2 {
t.Fatalf("observed = %+v/%v, want pts=2", observed, found)
}
}
func TestUpdateStateBaselineCommitIsAtomicAndMonotonic(t *testing.T) {
ctx := context.Background()
store := NewUpdateStateStore()
authKeyID := [8]byte{2}
const userID int64 = 1002
if err := store.CommitDeliveredState(ctx, authKeyID, userID, domain.UpdateState{Pts: 8, Date: 80}, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
t.Fatalf("commit baseline: %v", err)
}
if err := store.CommitDeliveredState(ctx, authKeyID, userID, domain.UpdateState{Pts: 3, Date: 30}, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
t.Fatalf("commit stale baseline: %v", err)
}
confirmed, _, _ := store.Get(ctx, authKeyID, userID)
observed, _ := store.ObservedClientState(authKeyID, userID)
if confirmed.Pts != 8 || observed.Pts != 8 {
t.Fatalf("out-of-order baseline regressed state: confirmed=%+v observed=%+v", confirmed, observed)
}
}
func TestUpdateStateDeleteAuthKeyRemovesObservedOnlyRows(t *testing.T) {
ctx := context.Background()
store := NewUpdateStateStore()
authKeyID := [8]byte{3}
if err := store.ObserveClientState(ctx, authKeyID, 1003, domain.UpdateState{Pts: 4}); err != nil {
t.Fatalf("observe: %v", err)
}
if err := store.DeleteAuthKey(ctx, authKeyID); err != nil {
t.Fatalf("delete auth key: %v", err)
}
if _, found := store.ObservedClientState(authKeyID, 1003); found {
t.Fatal("observed-only row survived auth-key deletion")
}
}

View file

@ -2,6 +2,7 @@ package memory
import (
"context"
"fmt"
"sync"
"telesrv/internal/domain"
)
@ -195,27 +196,44 @@ func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st
return nil
}
func (s *UpdateStateStore) CommitDeliveredState(_ context.Context, id [8]byte, userID int64, st domain.UpdateState, mode domain.UpdateStateCommitMode) error {
if mode != domain.UpdateStateCommitDeliveredOnly && mode != domain.UpdateStateCommitDeliveredAndObservedBaseline {
return fmt.Errorf("commit delivered update state: invalid mode %d", mode)
}
s.mu.Lock()
defer s.mu.Unlock()
key := updateStateKey{authKeyID: id, userID: userID}
s.states[key] = monotonicUpdateState(s.states[key], st)
if mode == domain.UpdateStateCommitDeliveredAndObservedBaseline {
s.observed[key] = monotonicUpdateState(s.observed[key], st)
}
return nil
}
func (s *UpdateStateStore) ObserveClientState(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
s.mu.Lock()
key := updateStateKey{authKeyID: id, userID: userID}
prev := s.observed[key]
if st.Pts < prev.Pts {
st.Pts = prev.Pts
}
if st.Qts < prev.Qts {
st.Qts = prev.Qts
}
if st.Date < prev.Date {
st.Date = prev.Date
}
if st.Seq < prev.Seq {
st.Seq = prev.Seq
}
s.observed[key] = st
s.observed[key] = monotonicUpdateState(s.observed[key], st)
s.mu.Unlock()
return nil
}
func monotonicUpdateState(prev, next domain.UpdateState) domain.UpdateState {
if next.Pts < prev.Pts {
next.Pts = prev.Pts
}
if next.Qts < prev.Qts {
next.Qts = prev.Qts
}
if next.Date < prev.Date {
next.Date = prev.Date
}
if next.Seq < prev.Seq {
next.Seq = prev.Seq
}
return next
}
// ObservedClientState 暴露给同包/服务测试验证 retention 安全水位;业务读路径仍用 Get。
func (s *UpdateStateStore) ObservedClientState(id [8]byte, userID int64) (domain.UpdateState, bool) {
s.mu.RLock()
@ -240,6 +258,11 @@ func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error {
delete(s.observed, k)
}
}
for k := range s.observed {
if k.authKeyID == id {
delete(s.observed, k)
}
}
s.mu.Unlock()
return nil
}

View file

@ -0,0 +1,222 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
const (
// authIdentityAdvisoryNamespace is deliberately a two-int advisory-lock
// namespace. PostgreSQL keeps it disjoint from the one-bigint advisory locks
// used elsewhere in the store. AUTH in ASCII is stable and recognizable in
// pg_locks diagnostics.
authIdentityAdvisoryNamespace int32 = 0x41555448
authIdentityTxMaxAttempts = 3
)
var errAuthIdentityChanged = errors.New("auth key permanent identity changed while acquiring locks")
type authKeyIdentityHint struct {
found bool
expiresAt int
bound bool
permID int64
identityID int64
hasIdentity bool
}
// withAuthIdentityTx gives identity-sensitive stores an explicit transaction
// boundary. Application-level identity visibility changes are retried after a
// savepoint/transaction rollback. PostgreSQL deadlock/serialization retries are
// only safe when this store owns the top-level transaction; an injected pgx.Tx
// is never silently replayed after 40P01/40001.
func withAuthIdentityTx(
ctx context.Context,
db sqlcgen.DBTX,
op string,
fn func(pgx.Tx) error,
) error {
_, embedded := db.(pgx.Tx)
var lastErr error
for attempt := 0; attempt < authIdentityTxMaxAttempts; attempt++ {
err := withTx(ctx, db, op, fn)
if err == nil {
return nil
}
lastErr = err
switch {
case errors.Is(err, errAuthIdentityChanged):
// The attempt owns a nested savepoint even for an injected pgx.Tx,
// so all row/advisory locks from the stale hint have been released
// before the next READ COMMITTED statement snapshot is taken.
continue
case !embedded && isAuthIdentityRetryableDatabaseError(err):
// Defensive retry only. The identity gate is the deadlock fix; this
// does not substitute for the global lock order.
continue
default:
return err
}
}
return fmt.Errorf("%s did not stabilize after %d attempts: %w", op, authIdentityTxMaxAttempts, lastErr)
}
func isAuthIdentityRetryableDatabaseError(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && (pgErr.Code == "40P01" || pgErr.Code == "40001")
}
// lockPermanentAuthIdentities acquires the complete batch before any auth-key,
// binding, authorization, or update-state row lock. Ordering is by the final
// int32 hashint8 key, not by the source bigint identity: hash collisions are
// intentionally one lock and cannot create an opposite acquisition order.
func lockPermanentAuthIdentities(ctx context.Context, tx pgx.Tx, permIDs []int64) error {
if len(permIDs) == 0 {
return nil
}
rows, err := tx.Query(ctx, `
SELECT DISTINCT hashint8(identity_id)::integer AS lock_key
FROM unnest($1::bigint[]) AS identities(identity_id)
ORDER BY lock_key`, permIDs)
if err != nil {
return fmt.Errorf("derive permanent auth identity lock keys: %w", err)
}
lockKeys := make([]int32, 0, len(permIDs))
for rows.Next() {
var key int32
if err := rows.Scan(&key); err != nil {
rows.Close()
return fmt.Errorf("scan permanent auth identity lock key: %w", err)
}
lockKeys = append(lockKeys, key)
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("iterate permanent auth identity lock keys: %w", err)
}
rows.Close()
for _, key := range lockKeys {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::integer, $2::integer)`, authIdentityAdvisoryNamespace, key); err != nil {
return fmt.Errorf("lock permanent auth identity %d: %w", key, err)
}
}
return nil
}
// lookupAuthKeyIdentityHint is intentionally lock-free. A positive-expiry raw
// key has no permanent identity until a binding is committed; a permanent raw
// key is its own identity. Callers must re-read after the raw row is locked.
func lookupAuthKeyIdentityHint(ctx context.Context, tx pgx.Tx, rawID int64) (authKeyIdentityHint, error) {
var hint authKeyIdentityHint
err := tx.QueryRow(ctx, `
/* auth_identity_hint */
SELECT key.expires_at,
binding.temp_auth_key_id IS NOT NULL,
COALESCE(binding.perm_auth_key_id, 0)
FROM auth_keys AS key
LEFT JOIN temp_auth_key_bindings AS binding
ON binding.temp_auth_key_id = key.auth_key_id
WHERE key.auth_key_id = $1`, rawID).Scan(&hint.expiresAt, &hint.bound, &hint.permID)
if errors.Is(err, pgx.ErrNoRows) {
return authKeyIdentityHint{}, nil
}
if err != nil {
return authKeyIdentityHint{}, fmt.Errorf("resolve auth key identity hint: %w", err)
}
hint.found = true
switch {
case hint.bound:
hint.identityID = hint.permID
hint.hasIdentity = true
case hint.expiresAt == 0:
hint.identityID = rawID
hint.hasIdentity = true
}
return hint, nil
}
// lockRawAuthKeyInIdentityOrder establishes the only cross-identity row-lock
// order used by bind, selector advance and direct key deletion:
//
// permanent identity advisory gate -> raw auth-key row -> permanent row
//
// If an initially-unbound temp key becomes bound before the raw lock is
// acquired, taking its newly discovered identity advisory lock at that point
// would recreate raw->identity inversion. The caller must roll back and retry.
func lockRawAuthKeyInIdentityOrder(
ctx context.Context,
tx pgx.Tx,
rawID int64,
) (rawExpiry int, permID int64, bound bool, err error) {
hint, err := lookupAuthKeyIdentityHint(ctx, tx, rawID)
if err != nil || !hint.found {
if err != nil {
return 0, 0, false, err
}
return 0, 0, false, store.ErrAuthKeyNotFound
}
if hint.hasIdentity {
if err := lockPermanentAuthIdentities(ctx, tx, []int64{hint.identityID}); err != nil {
return 0, 0, false, err
}
}
if err := tx.QueryRow(ctx, `
SELECT expires_at
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, rawID).Scan(&rawExpiry); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, 0, false, store.ErrAuthKeyNotFound
}
return 0, 0, false, fmt.Errorf("lock raw auth key: %w", err)
}
var actualPermID int64
err = tx.QueryRow(ctx, `
SELECT perm_auth_key_id
FROM temp_auth_key_bindings
WHERE temp_auth_key_id = $1`, rawID).Scan(&actualPermID)
switch {
case err == nil:
bound = true
permID = actualPermID
case errors.Is(err, pgx.ErrNoRows):
permID = rawID
default:
return 0, 0, false, fmt.Errorf("revalidate auth key permanent identity: %w", err)
}
actualHasIdentity := bound || rawExpiry == 0
actualIdentityID := permID
if actualHasIdentity != hint.hasIdentity ||
(actualHasIdentity && actualIdentityID != hint.identityID) ||
bound != hint.bound || rawExpiry != hint.expiresAt {
return 0, 0, false, errAuthIdentityChanged
}
if !bound {
return rawExpiry, permID, false, nil
}
var permExpiry int
if err := tx.QueryRow(ctx, `
SELECT expires_at
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, permID).Scan(&permExpiry); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, 0, false, store.ErrAuthKeyBindingInvalid
}
return 0, 0, false, fmt.Errorf("lock permanent auth key: %w", err)
}
if rawExpiry <= 0 || permExpiry != 0 {
return 0, 0, false, store.ErrAuthKeyBindingInvalid
}
return rawExpiry, permID, true, nil
}

View file

@ -0,0 +1,429 @@
package postgres
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAuthIdentitySelectorRetriesUncommittedFirstBindSnapshotPostgres(t *testing.T) {
pool := testPool(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
keys := NewAuthKeyStore(pool)
bindings := NewTempAuthKeyBindingStore(pool)
expiresAt := int(time.Now().Add(time.Hour).Unix())
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, expiresAt)
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 8701, TempSessionID: 8702, ExpiresAt: expiresAt,
EncryptedMessage: []byte("first bind snapshot"),
}
advanceConn, err := pool.Acquire(ctx)
if err != nil {
t.Fatal(err)
}
defer advanceConn.Release()
var advancePID int
if err := advanceConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&advancePID); err != nil {
t.Fatal(err)
}
barrier := newAuthStoreQueryBarrier(advanceConn, "auth_identity_hint", "")
msgID := authKeySessionLayerTestMsgID(time.Now().UTC(), 1)
type advanceResult struct {
value store.AuthKeySessionLayer
applied bool
err error
}
result := make(chan advanceResult, 1)
go func() {
value, applied, err := NewAuthKeyStore(barrier).AdvanceSessionLayer(ctx, temp, 8703, 227, msgID)
result <- advanceResult{value: value, applied: applied, err: err}
}()
<-barrier.observed
// The selector already read "unbound". Stage a committed binding behind
// its statement snapshot while retaining P/raw row locks in the outer tx.
bindTx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = bindTx.Rollback(context.Background()) }()
if err := NewTempAuthKeyBindingStore(bindTx).Save(ctx, binding); err != nil {
t.Fatalf("stage first bind: %v", err)
}
close(barrier.release)
waitForPostgresBackendLockWait(t, ctx, pool, advancePID)
if err := bindTx.Commit(ctx); err != nil {
t.Fatalf("commit first bind: %v", err)
}
got := <-result
if got.err != nil || !got.applied || got.value.Layer != 227 || !got.value.SharedDefault {
t.Fatalf("advance after identity retry = (%+v,%v,%v)", got.value, got.applied, got.err)
}
assertTempIdentityBinding(t, ctx, bindings, binding)
for _, id := range [][8]byte{temp, perm} {
stored, found, err := keys.Get(ctx, id)
if err != nil || !found || stored.Layer != 227 || stored.LayerObservationID != got.value.ObservationID {
t.Fatalf("shared tuple %x = (%+v,%v,%v)", id, stored, found, err)
}
}
}
func TestAuthIdentitySelectorSerializesWithPermanentRevocationAndDeletePostgres(t *testing.T) {
for _, op := range []string{"revoke", "delete"} {
t.Run(op, func(t *testing.T) {
pool := testPool(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
bindings := NewTempAuthKeyBindingStore(pool)
expiresAt := int(time.Now().Add(time.Hour).Unix())
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, expiresAt)
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
userID := createRevokeTestUser(t, ctx, pool, "selector-"+op)
hash := int64(8800)
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: perm, UserID: userID, Hash: hash}); err != nil {
t.Fatal(err)
}
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm), ExpiresAt: expiresAt,
EncryptedMessage: []byte("identity serialization"),
}); err != nil {
t.Fatal(err)
}
blocker, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = blocker.Rollback(context.Background()) }()
if err := lockPermanentAuthIdentities(ctx, blocker, []int64{authKeyIDToInt64(perm)}); err != nil {
t.Fatal(err)
}
selectorConn, err := pool.Acquire(ctx)
if err != nil {
t.Fatal(err)
}
defer selectorConn.Release()
opConn, err := pool.Acquire(ctx)
if err != nil {
t.Fatal(err)
}
defer opConn.Release()
var selectorPID, opPID int
if err := selectorConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&selectorPID); err != nil {
t.Fatal(err)
}
if err := opConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&opPID); err != nil {
t.Fatal(err)
}
selectorResult := make(chan error, 1)
go func() {
_, _, err := NewAuthKeyStore(selectorConn).AdvanceSessionLayer(
ctx, temp, 8801, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
)
selectorResult <- err
}()
waitForPostgresBackendLockWait(t, ctx, pool, selectorPID)
opResult := make(chan error, 1)
go func() {
if op == "revoke" {
_, found, err := NewAuthorizationStore(opConn).RevokeByHash(ctx, userID, hash)
if err == nil && !found {
err = errors.New("revoke target disappeared")
}
opResult <- err
return
}
opResult <- NewAuthKeyStore(opConn).Delete(ctx, perm)
}()
waitForPostgresBackendLockWait(t, ctx, pool, opPID)
if err := blocker.Commit(ctx); err != nil {
t.Fatal(err)
}
selectorErr := <-selectorResult
if selectorErr != nil &&
!errors.Is(selectorErr, store.ErrAuthKeyNotFound) &&
!errors.Is(selectorErr, store.ErrAuthKeyBindingInvalid) {
t.Fatalf("selector error = %v", selectorErr)
}
if err := <-opResult; err != nil {
t.Fatalf("%s error = %v", op, err)
}
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
assertTempIdentityAuthKeyMissing(t, ctx, keys, perm)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
t.Fatalf("binding after %s found=%v err=%v", op, found, err)
}
})
}
}
func TestAuthIdentityAuthorizationMirrorUsesLockedPrimaryLayerPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
t.Run("advance before stale bind", func(t *testing.T) {
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
userID := createRevokeTestUser(t, ctx, pool, "layer-advance-before-bind")
if _, _, err := keys.AdvanceSessionLayer(
ctx, perm, 8901, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
); err != nil {
t.Fatal(err)
}
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: perm, UserID: userID, Hash: 8902, Layer: 220,
}); err != nil {
t.Fatal(err)
}
got, found, err := auths.ByAuthKey(ctx, perm)
if err != nil || !found || got.Layer != 227 {
t.Fatalf("stale bind mirror = (%+v,%v,%v)", got, found, err)
}
})
t.Run("bind before advance", func(t *testing.T) {
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
userID := createRevokeTestUser(t, ctx, pool, "layer-bind-before-advance")
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: perm, UserID: userID, Hash: 8903, Layer: 220,
}); err != nil {
t.Fatal(err)
}
if _, _, err := keys.AdvanceSessionLayer(
ctx, perm, 8904, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 2),
); err != nil {
t.Fatal(err)
}
got, found, err := auths.ByAuthKey(ctx, perm)
if err != nil || !found || got.Layer != 227 {
t.Fatalf("advanced mirror = (%+v,%v,%v)", got, found, err)
}
})
}
func TestDeleteOrphanedRevalidatesUncommittedAuthorizationAndTempBindPostgres(t *testing.T) {
t.Run("authorization bind", func(t *testing.T) {
pool := testPool(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = now() - interval '72 hours' WHERE auth_key_id = $1`, authKeyIDToInt64(perm)); err != nil {
t.Fatal(err)
}
userID := createRevokeTestUser(t, ctx, pool, "orphan-auth-bind")
gcConn, err := pool.Acquire(ctx)
if err != nil {
t.Fatal(err)
}
defer gcConn.Release()
var gcPID int
if err := gcConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&gcPID); err != nil {
t.Fatal(err)
}
barrier := newAuthStoreQueryBarrier(gcConn, "", "orphan_identity_candidates")
gcResult := make(chan struct {
deleted int
err error
}, 1)
go func() {
deleted, err := NewAuthKeyStore(barrier).DeleteOrphaned(ctx, 24*time.Hour, 1, nil)
gcResult <- struct {
deleted int
err error
}{deleted: deleted, err: err}
}()
<-barrier.observed
bindTx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = bindTx.Rollback(context.Background()) }()
if err := NewAuthorizationStore(bindTx).Bind(ctx, domain.Authorization{
AuthKeyID: perm, UserID: userID, Hash: 9001,
}); err != nil {
t.Fatal(err)
}
close(barrier.release)
waitForPostgresBackendLockWait(t, ctx, pool, gcPID)
if err := bindTx.Commit(ctx); err != nil {
t.Fatal(err)
}
got := <-gcResult
if got.err != nil || got.deleted != 0 {
t.Fatalf("orphan GC after authorization bind = (%d,%v)", got.deleted, got.err)
}
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
assertRevokeTestPresentAuthorization(t, ctx, auths, perm)
})
t.Run("temporary bind", func(t *testing.T) {
pool := testPool(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
keys := NewAuthKeyStore(pool)
bindings := NewTempAuthKeyBindingStore(pool)
expiresAt := int(time.Now().Add(time.Hour).Unix())
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, expiresAt)
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = now() - interval '72 hours' WHERE auth_key_id = $1`, authKeyIDToInt64(temp)); err != nil {
t.Fatal(err)
}
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 9002, TempSessionID: 9003, ExpiresAt: expiresAt,
EncryptedMessage: []byte("orphan bind revalidation"),
}
gcConn, err := pool.Acquire(ctx)
if err != nil {
t.Fatal(err)
}
defer gcConn.Release()
barrier := newAuthStoreQueryBarrier(gcConn, "", "orphan_identity_candidates")
gcResult := make(chan struct {
deleted int
err error
}, 1)
go func() {
deleted, err := NewAuthKeyStore(barrier).DeleteOrphaned(ctx, 24*time.Hour, 1, nil)
gcResult <- struct {
deleted int
err error
}{deleted: deleted, err: err}
}()
<-barrier.observed
bindTx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = bindTx.Rollback(context.Background()) }()
if err := NewTempAuthKeyBindingStore(bindTx).Save(ctx, binding); err != nil {
t.Fatal(err)
}
close(barrier.release)
var gc struct {
deleted int
err error
}
select {
case gc = <-gcResult:
case <-time.After(2 * time.Second):
_ = bindTx.Commit(context.Background())
t.Fatal("orphan GC blocked on an uncommitted temp bind despite SKIP LOCKED")
}
if gc.err != nil || gc.deleted != 0 {
t.Fatalf("orphan GC during temp bind = (%d,%v)", gc.deleted, gc.err)
}
if err := bindTx.Commit(ctx); err != nil {
t.Fatal(err)
}
assertTempIdentityBinding(t, ctx, bindings, binding)
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
})
}
type authStoreQueryBarrier struct {
*pgxpool.Conn
queryRowMarker string
queryMarker string
observed chan struct{}
release chan struct{}
once sync.Once
}
func newAuthStoreQueryBarrier(conn *pgxpool.Conn, queryRowMarker, queryMarker string) *authStoreQueryBarrier {
return &authStoreQueryBarrier{
Conn: conn, queryRowMarker: queryRowMarker, queryMarker: queryMarker,
observed: make(chan struct{}), release: make(chan struct{}),
}
}
func (db *authStoreQueryBarrier) Begin(ctx context.Context) (pgx.Tx, error) {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return nil, err
}
return &authStoreBarrierTx{Tx: tx, owner: db}, nil
}
type authStoreBarrierTx struct {
pgx.Tx
owner *authStoreQueryBarrier
}
func (tx *authStoreBarrierTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
row := tx.Tx.QueryRow(ctx, sql, args...)
if tx.owner.queryRowMarker != "" && strings.Contains(sql, tx.owner.queryRowMarker) {
return &authStoreBarrierRow{Row: row, owner: tx.owner}
}
return row
}
func (tx *authStoreBarrierTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
rows, err := tx.Tx.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
if tx.owner.queryMarker != "" && strings.Contains(sql, tx.owner.queryMarker) {
return &authStoreBarrierRows{Rows: rows, owner: tx.owner}, nil
}
return rows, nil
}
type authStoreBarrierRow struct {
pgx.Row
owner *authStoreQueryBarrier
}
func (row *authStoreBarrierRow) Scan(dest ...any) error {
err := row.Row.Scan(dest...)
if err == nil {
row.owner.once.Do(func() {
close(row.owner.observed)
<-row.owner.release
})
}
return err
}
type authStoreBarrierRows struct {
pgx.Rows
owner *authStoreQueryBarrier
}
func (rows *authStoreBarrierRows) Next() bool {
next := rows.Rows.Next()
if !next {
rows.owner.once.Do(func() {
close(rows.owner.observed)
<-rows.owner.release
})
}
return next
}

View file

@ -8,7 +8,6 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/store"
@ -56,24 +55,26 @@ WHERE auth_keys.body = EXCLUDED.body
// 注册进 SessionManager”的窗口被后台清理。
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
var (
body []byte
serverSalt int64
expiresAt int
createdAt pgtype.Timestamptz
layer int
deviceModel string
platform string
systemVersion string
apiID int
appVersion string
body []byte
serverSalt int64
expiresAt int
createdAt pgtype.Timestamptz
layer int
layerObservationID int64
deviceModel string
platform string
systemVersion string
apiID int
appVersion string
)
err := s.db.QueryRow(ctx, `
UPDATE auth_keys
SET last_used_at = now()
WHERE auth_key_id = $1
RETURNING auth_key_id, body, server_salt, created_at,
expires_at, layer, device_model, platform, system_version, api_id, app_version
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &expiresAt, &layer, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
expires_at, layer, layer_observation_id,
device_model, platform, system_version, api_id, app_version
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &expiresAt, &layer, &layerObservationID, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeyData{}, false, nil
@ -84,15 +85,16 @@ RETURNING auth_key_id, body, server_salt, created_at,
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(body))
}
data := store.AuthKeyData{
ID: id,
ServerSalt: serverSalt,
ExpiresAt: expiresAt,
Layer: layer,
DeviceModel: deviceModel,
Platform: platform,
SystemVersion: systemVersion,
APIID: apiID,
AppVersion: appVersion,
ID: id,
ServerSalt: serverSalt,
ExpiresAt: expiresAt,
Layer: layer,
LayerObservationID: layerObservationID,
DeviceModel: deviceModel,
Platform: platform,
SystemVersion: systemVersion,
APIID: apiID,
AppVersion: appVersion,
}
copy(data.Value[:], body)
if createdAt.Valid {
@ -148,7 +150,8 @@ WHERE auth_key_id = ANY($1::bigint[])`, batch)
}
func (s *AuthKeyStore) UpdateClientInfo(ctx context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
if _, err := s.db.Exec(ctx, `
var updated int
err := s.db.QueryRow(ctx, `
UPDATE auth_keys
SET layer = CASE WHEN $2::integer > 0 THEN $2 ELSE layer END,
device_model = CASE WHEN $3::text <> '' THEN $3 ELSE device_model END,
@ -157,7 +160,31 @@ SET layer = CASE WHEN $2::integer > 0 THEN $2 ELSE layer END,
api_id = CASE WHEN $6::integer <> 0 THEN $6 ELSE api_id END,
app_version = CASE WHEN $7::text <> '' THEN $7 ELSE app_version END
WHERE auth_key_id = $1
`, authKeyIDToInt64(id), info.Layer, info.DeviceModel, info.Platform, info.SystemVersion, info.APIID, info.AppVersion); err != nil {
AND ($2::integer <= 0 OR layer_observation_id = 0 OR layer = $2::integer)
RETURNING 1
`, authKeyIDToInt64(id), info.Layer, info.DeviceModel, info.Platform, info.SystemVersion, info.APIID, info.AppVersion).Scan(&updated)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
var (
currentLayer int
observation int64
)
lookupErr := s.db.QueryRow(ctx, `
SELECT layer, layer_observation_id
FROM auth_keys
WHERE auth_key_id = $1
`, authKeyIDToInt64(id)).Scan(&currentLayer, &observation)
switch {
case errors.Is(lookupErr, pgx.ErrNoRows):
return store.ErrAuthKeyNotFound
case lookupErr != nil:
return fmt.Errorf("classify auth key client info update: %w", lookupErr)
case info.Layer > 0 && observation > 0 && currentLayer != info.Layer:
return store.ErrAuthKeySessionLayerConflict
default:
return fmt.Errorf("update auth key client info: guarded update affected no row")
}
}
return fmt.Errorf("update auth key client info: %w", err)
}
return nil
@ -171,25 +198,20 @@ WHERE auth_key_id = $1
// RESTRICT FK 防止悬空,因此被踢/销毁 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED而不是连接层 404。
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
for attempt := 0; attempt < 3; attempt++ {
err := s.deleteAuthKeyOnce(ctx, id)
if err == nil {
return nil
}
if !isPermAuthKeyDeleteRace(err) {
return err
}
if _, inTx := s.db.(pgx.Tx); inTx {
return err
}
}
return fmt.Errorf("delete auth key: permanent-key binding changed during all retries")
return withAuthIdentityTx(ctx, s.db, "delete auth key", func(tx pgx.Tx) error {
return deleteAuthKeyTx(ctx, tx, authKeyIDToInt64(id))
})
}
func (s *AuthKeyStore) deleteAuthKeyOnce(ctx context.Context, id [8]byte) error {
keyID := authKeyIDToInt64(id)
func deleteAuthKeyTx(ctx context.Context, tx pgx.Tx, keyID int64) error {
if _, _, _, err := lockRawAuthKeyInIdentityOrder(ctx, tx, keyID); err != nil {
if errors.Is(err, store.ErrAuthKeyNotFound) {
return nil
}
return err
}
var touched int
if err := s.db.QueryRow(ctx, `
if err := tx.QueryRow(ctx, `
WITH doomed_temp AS MATERIALIZED (
SELECT temp_auth_key_id
FROM temp_auth_key_bindings
@ -225,13 +247,6 @@ SELECT
const tempAuthKeyPermFKConstraint = "temp_auth_key_bindings_perm_auth_key_id_fkey"
func isPermAuthKeyDeleteRace(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) &&
pgErr.Code == "23503" &&
pgErr.ConstraintName == tempAuthKeyPermFKConstraint
}
// DeleteOrphaned 回收握手已落库、但从未形成 authorization/temp binding 且当前没有
// 活跃物理连接的旧 auth key。last_used_at 与 Get 的 UPDATE ... RETURNING 行锁配对,封住
// active-key 快照之后新连接开始使用旧 key 的竞态;所有引用条件仍在最终 DELETE 中复核。
@ -248,51 +263,140 @@ func (s *AuthKeyStore) DeleteOrphaned(ctx context.Context, olderThan time.Durati
protectedIDs = append(protectedIDs, authKeyIDToInt64(id))
}
var deleted int
err := s.db.QueryRow(ctx, `
WITH candidates AS MATERIALIZED (
err := withAuthIdentityTx(ctx, s.db, "delete orphaned auth keys", func(tx pgx.Tx) error {
var err error
deleted, err = deleteOrphanedAuthKeysTx(ctx, tx, olderThan, limit, protectedIDs)
return err
})
if err != nil {
return 0, fmt.Errorf("delete orphaned auth keys: %w", err)
}
return deleted, nil
}
func deleteOrphanedAuthKeysTx(
ctx context.Context,
tx pgx.Tx,
olderThan time.Duration,
limit int,
protectedIDs []int64,
) (int, error) {
// Phase 1 is only a bounded hint. It must not lock rows before the complete
// permanent-identity advisory set has been derived and acquired.
rows, err := tx.Query(ctx, `
/* orphan_identity_candidates */
SELECT k.auth_key_id, k.expires_at
FROM auth_keys AS k
WHERE k.last_used_at < now() - make_interval(secs => $1::double precision)
AND NOT (k.auth_key_id = ANY($2::bigint[]))
AND NOT EXISTS (
SELECT 1 FROM authorizations AS a WHERE a.auth_key_id = k.auth_key_id
)
AND NOT EXISTS (
SELECT 1
FROM temp_auth_key_bindings AS b
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
)
ORDER BY k.last_used_at, k.auth_key_id
LIMIT $3`, olderThan.Seconds(), protectedIDs, limit)
if err != nil {
return 0, fmt.Errorf("select orphan auth key candidates: %w", err)
}
candidates := make([]int64, 0, limit)
permanentCandidates := make([]int64, 0, limit)
for rows.Next() {
var (
id int64
expiresAt int
)
if err := rows.Scan(&id, &expiresAt); err != nil {
rows.Close()
return 0, fmt.Errorf("scan orphan auth key candidate: %w", err)
}
candidates = append(candidates, id)
if expiresAt == 0 {
permanentCandidates = append(permanentCandidates, id)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, fmt.Errorf("iterate orphan auth key candidates: %w", err)
}
rows.Close()
if len(candidates) == 0 {
return 0, nil
}
if err := lockPermanentAuthIdentities(ctx, tx, permanentCandidates); err != nil {
return 0, err
}
// Phase 2 locks only the hinted raw rows, in real-ID order, after every P
// gate is held. A temp bind already holding its raw row is skipped; if it
// committed immediately before this lock, phase 3's new READ COMMITTED
// statement sees the binding and excludes it.
lockRows, err := tx.Query(ctx, `
SELECT auth_key_id
FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])
ORDER BY auth_key_id
FOR UPDATE SKIP LOCKED`, candidates)
if err != nil {
return 0, fmt.Errorf("lock orphan auth key candidates: %w", err)
}
lockedIDs := make([]int64, 0, len(candidates))
for lockRows.Next() {
var id int64
if err := lockRows.Scan(&id); err != nil {
lockRows.Close()
return 0, fmt.Errorf("scan locked orphan auth key: %w", err)
}
lockedIDs = append(lockedIDs, id)
}
if err := lockRows.Err(); err != nil {
lockRows.Close()
return 0, fmt.Errorf("iterate locked orphan auth keys: %w", err)
}
lockRows.Close()
if len(lockedIDs) == 0 {
return 0, nil
}
// Phase 3 is a separate statement snapshot and repeats every ownership,
// activity and protection predicate. Never delete from the phase-1 hint.
var deleted int
err = tx.QueryRow(ctx, `
WITH still_orphaned AS MATERIALIZED (
SELECT k.auth_key_id
FROM auth_keys k
WHERE k.last_used_at < now() - make_interval(secs => $1::double precision)
AND NOT (k.auth_key_id = ANY($2::bigint[]))
AND NOT EXISTS (
SELECT 1 FROM authorizations a WHERE a.auth_key_id = k.auth_key_id
)
AND NOT EXISTS (
SELECT 1
FROM temp_auth_key_bindings b
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
)
ORDER BY k.last_used_at ASC, k.auth_key_id ASC
LIMIT $3
FOR UPDATE OF k SKIP LOCKED
), deleted_update_states AS (
-- Historical authorization-only deletion could leave a cursor without an
-- auth_keys FK. GC owns that stale row once the raw key is proven orphaned.
DELETE FROM update_states s
USING candidates c
WHERE s.auth_key_id = c.auth_key_id
RETURNING s.auth_key_id
), deleted_keys AS (
DELETE FROM auth_keys k
USING candidates c
WHERE k.auth_key_id = c.auth_key_id
FROM auth_keys AS k
WHERE k.auth_key_id = ANY($3::bigint[])
AND k.last_used_at < now() - make_interval(secs => $1::double precision)
AND NOT (k.auth_key_id = ANY($2::bigint[]))
AND NOT EXISTS (
SELECT 1 FROM authorizations a WHERE a.auth_key_id = k.auth_key_id
SELECT 1 FROM authorizations AS a WHERE a.auth_key_id = k.auth_key_id
)
AND NOT EXISTS (
SELECT 1
FROM temp_auth_key_bindings b
FROM temp_auth_key_bindings AS b
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
)
RETURNING k.auth_key_id
), deleted_update_states AS (
DELETE FROM update_states AS state
USING still_orphaned AS orphan
WHERE state.auth_key_id = orphan.auth_key_id
RETURNING state.auth_key_id
), deleted_keys AS (
DELETE FROM auth_keys AS key
USING still_orphaned AS orphan
WHERE key.auth_key_id = orphan.auth_key_id
RETURNING key.auth_key_id
)
SELECT count(*)::int
SELECT count(*)::integer
FROM deleted_keys
CROSS JOIN LATERAL (SELECT count(*) FROM deleted_update_states) AS touched`, olderThan.Seconds(), protectedIDs, limit).Scan(&deleted)
CROSS JOIN LATERAL (SELECT count(*) FROM deleted_update_states) AS touched`,
olderThan.Seconds(), protectedIDs, lockedIDs,
).Scan(&deleted)
if err != nil {
return 0, fmt.Errorf("delete orphaned auth keys: %w", err)
return 0, fmt.Errorf("delete revalidated orphan auth keys: %w", err)
}
return deleted, nil
}

View file

@ -144,3 +144,69 @@ func TestAuthKeyStoreClientInfoRoundTrip(t *testing.T) {
t.Fatalf("partial client info merge mismatch: %+v", got)
}
}
func TestAuthKeyStoreUpdateClientInfoProtectsObservedLayerPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
keys := NewAuthKeyStore(pool)
var id [8]byte
var value [256]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(value[:]); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
if err := keys.Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
t.Fatalf("save auth key: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE auth_keys
SET layer = 227, layer_observation_id = 91,
device_model = 'before', platform = 'tdesktop'
WHERE auth_key_id = $1`, authKeyIDToInt64(id)); err != nil {
t.Fatalf("seed ordered layer: %v", err)
}
err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Layer: 220, DeviceModel: "must-not-merge", AppVersion: "must-not-merge",
})
if !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
t.Fatalf("conflicting layer update error = %v, want %v", err, store.ErrAuthKeySessionLayerConflict)
}
got, found, err := keys.Get(ctx, id)
if err != nil || !found {
t.Fatalf("get after conflict: found=%v err=%v", found, err)
}
if got.Layer != 227 || got.LayerObservationID != 91 || got.DeviceModel != "before" ||
got.Platform != "tdesktop" || got.AppVersion != "" {
t.Fatalf("conflicting update changed row: %+v", got)
}
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Layer: 227, DeviceModel: "same-layer", AppVersion: "1.0",
}); err != nil {
t.Fatalf("same observed layer metadata merge: %v", err)
}
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Platform: "windows", SystemVersion: "11",
}); err != nil {
t.Fatalf("layerless metadata merge: %v", err)
}
got, found, err = keys.Get(ctx, id)
if err != nil || !found {
t.Fatalf("get guarded metadata merge: found=%v err=%v", found, err)
}
if got.Layer != 227 || got.LayerObservationID != 91 || got.DeviceModel != "same-layer" ||
got.Platform != "windows" || got.SystemVersion != "11" || got.AppVersion != "1.0" {
t.Fatalf("guarded metadata merge = %+v", got)
}
missing := id
missing[0] ^= 0xff
if err := keys.UpdateClientInfo(ctx, missing, store.AuthKeyClientInfo{Layer: 227}); !errors.Is(err, store.ErrAuthKeyNotFound) {
t.Fatalf("missing primary update error = %v, want %v", err, store.ErrAuthKeyNotFound)
}
}

View file

@ -0,0 +1,245 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/store"
)
const maxAuthKeySessionLayerDeleteBatch = 100000
func (s *AuthKeyStore) GetSessionLayer(
ctx context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
) (store.AuthKeySessionLayer, bool, error) {
var value store.AuthKeySessionLayer
err := s.db.QueryRow(ctx, `
SELECT evidence.layer,
evidence.msg_id,
evidence.observation_id,
evidence.expires_at,
defaults.layer = evidence.layer
AND defaults.layer_observation_id = evidence.observation_id
FROM auth_key_session_layers AS evidence
LEFT JOIN temp_auth_key_bindings AS binding
ON binding.temp_auth_key_id = evidence.raw_auth_key_id
JOIN auth_keys AS defaults
ON defaults.auth_key_id = COALESCE(binding.perm_auth_key_id, evidence.raw_auth_key_id)
WHERE evidence.raw_auth_key_id = $1
AND evidence.session_id = $2
AND evidence.expires_at > now()
`, authKeyIDToInt64(rawAuthKeyID), sessionID).Scan(
&value.Layer,
&value.MessageID,
&value.ObservationID,
&value.ExpiresAt,
&value.SharedDefault,
)
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeySessionLayer{}, false, nil
}
if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("get auth key session layer: %w", err)
}
return value, true, nil
}
// AdvanceSessionLayer enters the permanent identity advisory gate before any
// row lock when rawAuthKeyID is permanent or already-bound temporary. An
// initially-unbound temp key that becomes bound while the raw row is acquired
// rolls the attempt back and retries in the new identity. The session watermark
// and every currently bound shared default then commit in one transaction.
func (s *AuthKeyStore) AdvanceSessionLayer(
ctx context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
layer int,
msgID int64,
) (store.AuthKeySessionLayer, bool, error) {
expiresAt, validMessageID := store.AuthKeySessionLayerExpiry(msgID)
if layer <= 0 || !validMessageID {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
}
var (
current store.AuthKeySessionLayer
applied bool
)
err := withAuthIdentityTx(ctx, s.db, "advance auth key session layer", func(tx pgx.Tx) error {
var err error
current, applied, err = advanceSessionLayerTx(
ctx, tx, authKeyIDToInt64(rawAuthKeyID), sessionID, layer, msgID, expiresAt,
)
return err
})
if err != nil {
return current, false, err
}
return current, applied, nil
}
func advanceSessionLayerTx(
ctx context.Context,
tx pgx.Tx,
rawID int64,
sessionID int64,
layer int,
msgID int64,
expiresAt time.Time,
) (store.AuthKeySessionLayer, bool, error) {
_, permID, _, err := lockRawAuthKeyInIdentityOrder(ctx, tx, rawID)
if err != nil {
return store.AuthKeySessionLayer{}, false, err
}
var (
current store.AuthKeySessionLayer
now time.Time
)
err = tx.QueryRow(ctx, `
SELECT layer, msg_id, observation_id, expires_at, now()
FROM auth_key_session_layers
WHERE raw_auth_key_id = $1 AND session_id = $2
FOR UPDATE
`, rawID, sessionID).Scan(
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
&now,
)
if errors.Is(err, pgx.ErrNoRows) {
if err := tx.QueryRow(ctx, `SELECT now()`).Scan(&now); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("read session layer database time: %w", err)
}
current = store.AuthKeySessionLayer{}
} else if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("lock auth key session layer: %w", err)
}
if _, fresh := store.AuthKeySessionLayerEvidenceFresh(now, msgID); !fresh {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
}
if current.MessageID != 0 && now.Before(current.ExpiresAt) {
switch {
case msgID < current.MessageID:
if err := tx.QueryRow(ctx, `
SELECT layer = $2 AND layer_observation_id = $3
FROM auth_keys WHERE auth_key_id = $1
`, permID, current.Layer, current.ObservationID).Scan(&current.SharedDefault); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare older session layer with shared default: %w", err)
}
return current, false, nil
case msgID == current.MessageID:
if layer != current.Layer {
return current, false, store.ErrAuthKeySessionLayerConflict
}
if err := tx.QueryRow(ctx, `
SELECT layer = $2 AND layer_observation_id = $3
FROM auth_keys WHERE auth_key_id = $1
`, permID, current.Layer, current.ObservationID).Scan(&current.SharedDefault); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare duplicate session layer with shared default: %w", err)
}
return current, false, nil
}
}
var observationID int64
if err := tx.QueryRow(ctx, `SELECT nextval('auth_key_layer_observation_seq')`).Scan(&observationID); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("allocate auth key layer observation: %w", err)
}
err = tx.QueryRow(ctx, `
INSERT INTO auth_key_session_layers (
raw_auth_key_id, session_id, layer, msg_id, observation_id, expires_at
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (raw_auth_key_id, session_id) DO UPDATE SET
layer = EXCLUDED.layer,
msg_id = EXCLUDED.msg_id,
observation_id = EXCLUDED.observation_id,
expires_at = EXCLUDED.expires_at
RETURNING layer, msg_id, observation_id, expires_at
`, rawID, sessionID, layer, msgID, observationID, expiresAt).Scan(
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
)
if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("upsert auth key session layer: %w", err)
}
keyIDs := []int64{rawID}
if permID != rawID {
keyIDs = append(keyIDs, permID)
}
tag, err := tx.Exec(ctx, `
UPDATE auth_keys
SET layer = $2, layer_observation_id = $3
WHERE auth_key_id = ANY($1::bigint[])
AND layer_observation_id < $3
`, keyIDs, layer, observationID)
if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: %w", err)
}
if tag.RowsAffected() != int64(len(keyIDs)) {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: updated %d of %d locked keys", tag.RowsAffected(), len(keyIDs))
}
if _, err := tx.Exec(ctx, `
UPDATE authorizations
SET layer = $2
WHERE auth_key_id = ANY($1::bigint[])
`, keyIDs, layer); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("mirror auth key session layer defaults: %w", err)
}
current.SharedDefault = true
return current, true, nil
}
func (s *AuthKeyStore) DeleteSessionLayer(
ctx context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
) (bool, error) {
tag, err := s.db.Exec(ctx, `
DELETE FROM auth_key_session_layers
WHERE raw_auth_key_id = $1 AND session_id = $2
`, authKeyIDToInt64(rawAuthKeyID), sessionID)
if err != nil {
return false, fmt.Errorf("delete auth key session layer: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *AuthKeyStore) DeleteExpiredSessionLayers(ctx context.Context, limit int) (int, error) {
if limit <= 0 {
return 0, nil
}
if limit > maxAuthKeySessionLayerDeleteBatch {
limit = maxAuthKeySessionLayerDeleteBatch
}
var deleted int
err := s.db.QueryRow(ctx, `
WITH candidates AS MATERIALIZED (
SELECT raw_auth_key_id, session_id
FROM auth_key_session_layers
WHERE expires_at <= now()
ORDER BY expires_at, raw_auth_key_id, session_id
LIMIT $1
FOR UPDATE SKIP LOCKED
), removed AS (
DELETE FROM auth_key_session_layers AS evidence
USING candidates
WHERE evidence.raw_auth_key_id = candidates.raw_auth_key_id
AND evidence.session_id = candidates.session_id
AND evidence.expires_at <= now()
RETURNING 1
)
SELECT count(*)::integer FROM removed
`, limit).Scan(&deleted)
if err != nil {
return 0, fmt.Errorf("delete expired auth key session layers: %w", err)
}
return deleted, nil
}

View file

@ -0,0 +1,140 @@
package postgres
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"sync"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
temp := randomLayerTestAuthKeyID(t)
perm := randomLayerTestAuthKeyID(t)
for perm == temp {
perm = randomLayerTestAuthKeyID(t)
}
const sessionID = int64(87001)
t.Cleanup(func() {
_ = NewAuthKeyStore(pool).Delete(ctx, perm)
_ = NewAuthKeyStore(pool).Delete(ctx, temp)
})
keys := NewAuthKeyStore(pool)
expiresAt := int(time.Now().Add(time.Hour).Unix())
if err := keys.Save(ctx, store.AuthKeyData{ID: temp, ExpiresAt: expiresAt}); err != nil {
t.Fatal(err)
}
if err := keys.Save(ctx, store.AuthKeyData{ID: perm, ExpiresAt: 0}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
concurrentLowMsgID := authKeySessionLayerTestMsgID(now, 3)
concurrentHighMsgID := authKeySessionLayerTestMsgID(now, 4)
for _, invalidMsgID := range []int64{
authKeySessionLayerTestMsgID(now.Add(-302*time.Second), 1),
authKeySessionLayerTestMsgID(now.Add(31*time.Second), 1),
firstMsgID + 1,
} {
if _, _, err := keys.AdvanceSessionLayer(ctx, temp, sessionID, 220, invalidMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerInvalid) {
t.Fatalf("invalid msg_id %d advance err = %v", invalidMsgID, err)
}
}
if _, found, err := keys.GetSessionLayer(ctx, temp, sessionID); err != nil || found {
t.Fatalf("rejected evidence created session row: found=%v err=%v", found, err)
}
first, applied, err := keys.AdvanceSessionLayer(ctx, temp, sessionID, 220, firstMsgID)
if err != nil || !applied || !first.SharedDefault || first.ObservationID <= 0 {
t.Fatalf("first advance = (%+v,%v,%v)", first, applied, err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: int64(binary.LittleEndian.Uint64(perm[:])),
Nonce: 87,
TempSessionID: sessionID,
ExpiresAt: expiresAt,
EncryptedMessage: []byte{8, 7},
}); err != nil {
t.Fatal(err)
}
for _, id := range [][8]byte{temp, perm} {
got, found, err := NewAuthKeyStore(pool).Get(ctx, id)
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
}
}
newer, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 227, newerMsgID)
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
t.Fatalf("newer advance = (%+v,%v,%v)", newer, applied, err)
}
old, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, firstMsgID)
if err != nil || applied || old.Layer != 227 || old.MessageID != newerMsgID || !old.SharedDefault {
t.Fatalf("old replay = (%+v,%v,%v)", old, applied, err)
}
if _, _, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, newerMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
t.Fatalf("same-msg conflict = %v", err)
}
// Two independent store instances model two server processes. The raw-key
// row lock and session CAS must converge on the greater selector msg_id.
type candidate struct {
layer int
msgID int64
}
candidates := []candidate{{layer: 225, msgID: concurrentLowMsgID}, {layer: 227, msgID: concurrentHighMsgID}}
start := make(chan struct{})
errs := make(chan error, len(candidates))
var wg sync.WaitGroup
for _, item := range candidates {
item := item
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, item.layer, item.msgID)
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
restarted := NewAuthKeyStore(pool)
current, found, err := restarted.GetSessionLayer(ctx, temp, sessionID)
if err != nil || !found || current.Layer != 227 || current.MessageID != concurrentHighMsgID || !current.SharedDefault {
t.Fatalf("restart authoritative row = (%+v,%v,%v)", current, found, err)
}
for _, id := range [][8]byte{temp, perm} {
got, found, err := restarted.Get(ctx, id)
if err != nil || !found || got.Layer != 227 || got.LayerObservationID != current.ObservationID {
t.Fatalf("transactional shared default %x = (%+v,%v,%v)", id, got, found, err)
}
}
}
func authKeySessionLayerTestMsgID(at time.Time, order uint32) int64 {
return int64((uint64(at.Unix()) << 32) | uint64(order)<<2)
}
func randomLayerTestAuthKeyID(t *testing.T) [8]byte {
t.Helper()
var id [8]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
return id
}

View file

@ -29,17 +29,9 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
if a.Hash == 0 {
a.Hash = authorizationHash(a.AuthKeyID)
}
bind := func(db sqlcgen.DBTX) error {
return bindAuthorization(ctx, db, a)
}
var err error
if tx, ok := s.db.(pgx.Tx); ok {
err = bind(tx)
} else {
err = withTx(ctx, s.db, "bind authorization", func(tx pgx.Tx) error {
return bind(tx)
})
}
err := withAuthIdentityTx(ctx, s.db, "bind authorization", func(tx pgx.Tx) error {
return bindAuthorization(ctx, tx, a)
})
if err != nil {
return fmt.Errorf("upsert authorization: %w", err)
}
@ -55,20 +47,35 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
// raw auth key 的并发登录/换号。
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
keyID := authKeyIDToInt64(a.AuthKeyID)
tx, ok := db.(pgx.Tx)
if !ok {
return fmt.Errorf("bind authorization requires a transaction")
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{keyID}); err != nil {
return err
}
var (
lockedKeyID int64
expiresAt int
lockedKeyID int64
expiresAt int
authLayer int
layerObservationID int64
)
if err := db.QueryRow(ctx, `
SELECT auth_key_id, expires_at
SELECT auth_key_id, expires_at, layer, layer_observation_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, keyID).Scan(&lockedKeyID, &expiresAt); err != nil {
FOR UPDATE`, keyID).Scan(&lockedKeyID, &expiresAt, &authLayer, &layerObservationID); err != nil {
return fmt.Errorf("lock auth key for authorization: %w", err)
}
if expiresAt != 0 {
return store.ErrAuthKeyNotPermanent
}
if authLayer < 0 || layerObservationID < 0 || (layerObservationID > 0 && authLayer == 0) {
return fmt.Errorf(
"authorization auth-key layer invariant violation: auth key %x has layer %d observation %d",
a.AuthKeyID, authLayer, layerObservationID,
)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
@ -154,7 +161,7 @@ ON CONFLICT (auth_key_id) DO UPDATE SET
ip = EXCLUDED.ip,
password_pending = EXCLUDED.password_pending,
active_at = now()`,
keyID, a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
keyID, a.UserID, a.Hash, int32(authLayer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
); err != nil {
return fmt.Errorf("write authorization: %w", err)
}
@ -178,18 +185,6 @@ FROM authorizations WHERE auth_key_id = $1`, authKeyIDToInt64(id))
return a, true, nil
}
func (s *AuthorizationStore) UpdateLayer(ctx context.Context, id [8]byte, layer int) error {
if layer <= 0 {
return nil
}
if _, err := s.db.Exec(ctx, `
UPDATE authorizations SET layer = $2, active_at = now() WHERE auth_key_id = $1`,
authKeyIDToInt64(id), int32(layer)); err != nil {
return fmt.Errorf("update authorization layer: %w", err)
}
return nil
}
func (s *AuthorizationStore) UpdateClientInfo(ctx context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
if _, err := s.db.Exec(ctx, `
UPDATE authorizations SET
@ -261,34 +256,19 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
// authorizations 通过 FK cascade 删除update_states 没有 auth_keys FK必须显式清理
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
for attempt := 0; attempt < 3; attempt++ {
var (
a domain.Authorization
found bool
)
err := s.withRevocationTx(ctx, "revoke authorization by hash", func(tx pgx.Tx) error {
var err error
a, found, err = revokeByHashTx(ctx, tx, userID, hash)
return err
})
if err == nil {
return a, found, nil
}
if !isPermAuthKeyDeleteRace(err) {
return domain.Authorization{}, false, err
}
if _, inTx := s.db.(pgx.Tx); inTx {
return domain.Authorization{}, false, err
}
var (
a domain.Authorization
found bool
)
err := withAuthIdentityTx(ctx, s.db, "revoke authorization by hash", func(tx pgx.Tx) error {
var err error
a, found, err = revokeByHashTx(ctx, tx, userID, hash)
return err
})
if err != nil {
return domain.Authorization{}, false, err
}
return domain.Authorization{}, false, fmt.Errorf("revoke authorization by hash: permanent-key binding changed during all retries")
}
func (s *AuthorizationStore) withRevocationTx(ctx context.Context, op string, fn func(pgx.Tx) error) error {
if tx, ok := s.db.(pgx.Tx); ok {
return fn(tx)
}
return withTx(ctx, s.db, op, fn)
return a, found, nil
}
// revokeByHashTx deliberately uses separate READ COMMITTED statements. The first
@ -307,6 +287,9 @@ WHERE user_id = $1 AND hash = $2`, userID, hash).Scan(&candidate); err != nil {
}
return domain.Authorization{}, false, fmt.Errorf("select revoke candidate by hash: %w", err)
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
var locked int64
if err := tx.QueryRow(ctx, `
@ -368,24 +351,13 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
// RevokeByUserExcept 批量删除协议 auth_key保留 keepAuthKeyID 对应的当前设备。
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
for attempt := 0; attempt < 3; attempt++ {
var out []domain.Authorization
err := s.withRevocationTx(ctx, "revoke authorizations by user", func(tx pgx.Tx) error {
var err error
out, err = revokeByUserExceptTx(ctx, tx, userID, authKeyIDToInt64(keepAuthKeyID))
return err
})
if err == nil {
return out, nil
}
if !isPermAuthKeyDeleteRace(err) {
return nil, err
}
if _, inTx := s.db.(pgx.Tx); inTx {
return nil, err
}
}
return nil, fmt.Errorf("revoke authorizations by user: permanent-key binding changed during all retries")
var out []domain.Authorization
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
var err error
out, err = revokeByUserExceptTx(ctx, tx, userID, authKeyIDToInt64(keepAuthKeyID))
return err
})
return out, err
}
func revokeByUserExceptTx(ctx context.Context, tx pgx.Tx, userID, keepAuthKeyID int64) ([]domain.Authorization, error) {
@ -414,9 +386,12 @@ ORDER BY auth_key_id`, userID, keepAuthKeyID)
if len(candidates) == 0 {
return []domain.Authorization{}, nil
}
if err := lockPermanentAuthIdentities(ctx, tx, candidates); err != nil {
return nil, err
}
// Stable parent-row lock order matches every concurrent batch revocation and
// serializes each candidate with Bind's auth_keys-first ownership change.
// Advisory keys are already all held in final int32-hash order. Parent rows
// are then locked by their real bigint IDs for deterministic batch behavior.
lockRows, err := tx.Query(ctx, `
SELECT auth_key_id
FROM auth_keys

View file

@ -16,19 +16,99 @@ import (
// TempAuthKeyBindingStore 用 PostgreSQL 实现 store.TempAuthKeyBindingStore。
type TempAuthKeyBindingStore struct {
q *sqlcgen.Queries
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewTempAuthKeyBindingStore 基于 pgx 连接池(或事务)创建 TempAuthKeyBindingStore。
func NewTempAuthKeyBindingStore(db sqlcgen.DBTX) *TempAuthKeyBindingStore {
return &TempAuthKeyBindingStore{q: sqlcgen.New(db)}
return &TempAuthKeyBindingStore{db: db, q: sqlcgen.New(db)}
}
func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error {
if b.ExpiresAt <= 0 || int64(b.ExpiresAt) > math.MaxInt32 {
return store.ErrAuthKeyBindingInvalid
}
n, err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
return withAuthIdentityTx(ctx, s.db, "save temp auth key binding", func(tx pgx.Tx) error {
return s.saveTx(ctx, tx, b)
})
}
func (s *TempAuthKeyBindingStore) saveTx(ctx context.Context, tx pgx.Tx, b domain.TempAuthKeyBinding) error {
rawID := authKeyIDToInt64(b.TempAuthKeyID)
permID := b.PermAuthKeyID
// Every operation that may bridge temp and permanent rows enters the
// permanent identity gate before taking the raw-key row lock. This is the
// same gate/order used by selector advance and permanent revocation.
if err := lockPermanentAuthIdentities(ctx, tx, []int64{permID}); err != nil {
return err
}
var (
tempExpiry int
tempLayer int
tempObservationID int64
)
if err := tx.QueryRow(ctx, `
SELECT expires_at, layer, layer_observation_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE
`, rawID).Scan(&tempExpiry, &tempLayer, &tempObservationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.ErrAuthKeyBindingInvalid
}
return fmt.Errorf("lock temporary auth key for binding: %w", err)
}
if tempExpiry <= 0 || tempExpiry != b.ExpiresAt || rawID == permID {
return store.ErrAuthKeyBindingInvalid
}
// The raw row serializes first bind and rebind attempts. Read the binding
// only after taking that lock, so a concurrent winner is either visible or
// still waiting behind us. A different permanent identity is immutable.
var currentPermID int64
err := tx.QueryRow(ctx, `
SELECT perm_auth_key_id
FROM temp_auth_key_bindings
WHERE temp_auth_key_id = $1
`, rawID).Scan(&currentPermID)
switch {
case err == nil && currentPermID != permID:
return store.ErrTempAuthKeyAlreadyBound
case err != nil && !errors.Is(err, pgx.ErrNoRows):
return fmt.Errorf("read existing temporary auth key binding: %w", err)
}
var (
permExpiry int
permLayer int
permObservationID int64
)
if err := tx.QueryRow(ctx, `
SELECT expires_at, layer, layer_observation_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE
`, permID).Scan(&permExpiry, &permLayer, &permObservationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.ErrAuthKeyBindingInvalid
}
return fmt.Errorf("lock permanent auth key for binding: %w", err)
}
if permExpiry != 0 {
return store.ErrAuthKeyBindingInvalid
}
mergedLayer, mergedObservationID, err := store.MergeAuthKeyLayerObservations(
tempLayer, tempObservationID,
permLayer, permObservationID,
)
if err != nil {
return err
}
q := s.q.WithTx(tx)
n, err := q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
TempAuthKeyID: authKeyIDToInt64(b.TempAuthKeyID),
PermAuthKeyID: b.PermAuthKeyID,
Nonce: b.Nonce,
@ -44,13 +124,28 @@ func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKey
return fmt.Errorf("upsert temp auth key binding: %w", err)
}
if n == 0 {
if current, found, getErr := s.GetByTemp(ctx, b.TempAuthKeyID); getErr != nil {
return getErr
} else if found && current.PermAuthKeyID != b.PermAuthKeyID {
return store.ErrTempAuthKeyAlreadyBound
}
return store.ErrAuthKeyBindingInvalid
}
keyIDs := []int64{rawID, permID}
tag, err := tx.Exec(ctx, `
UPDATE auth_keys
SET layer = $2,
layer_observation_id = $3
WHERE auth_key_id = ANY($1::bigint[])
`, keyIDs, mergedLayer, mergedObservationID)
if err != nil {
return fmt.Errorf("merge bound auth key layer defaults: %w", err)
}
if tag.RowsAffected() != int64(len(keyIDs)) {
return fmt.Errorf("merge bound auth key layer defaults: updated %d of %d locked keys", tag.RowsAffected(), len(keyIDs))
}
if _, err := tx.Exec(ctx, `
UPDATE authorizations
SET layer = $2
WHERE auth_key_id = ANY($1::bigint[])
`, keyIDs, mergedLayer); err != nil {
return fmt.Errorf("mirror bound auth key layer default: %w", err)
}
return nil
}

View file

@ -11,7 +11,6 @@ import (
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
@ -133,6 +132,90 @@ func TestTempAuthKeyBindingStorePreservesHandshakeExpiryAndRejectsRebindPostgres
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
}
func TestTempAuthKeyBindingStoreAtomicallyMergesLayerObservationsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
tests := []struct {
name string
tempLayer int
tempObs int64
permLayer int
permObs int64
wantLayer int
wantObs int64
wantErr error
}{
{name: "temporary newer", tempLayer: 227, tempObs: 20, permLayer: 220, permObs: 10, wantLayer: 227, wantObs: 20},
{name: "permanent newer", tempLayer: 220, tempObs: 10, permLayer: 227, permObs: 20, wantLayer: 227, wantObs: 20},
{name: "equal ordered same layer", tempLayer: 225, tempObs: 30, permLayer: 225, permObs: 30, wantLayer: 225, wantObs: 30},
{name: "equal ordered conflict", tempLayer: 220, tempObs: 30, permLayer: 227, permObs: 30, wantErr: store.ErrAuthKeySessionLayerConflict},
{name: "legacy permanent wins", tempLayer: 220, permLayer: 227, wantLayer: 227},
}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
userID := createRevokeTestUser(t, ctx, pool, fmt.Sprintf("layer-merge-%d", i))
keys := NewAuthKeyStore(pool)
bindings := NewTempAuthKeyBindingStore(pool)
auths := NewAuthorizationStore(pool)
handshakeExpiry := int(time.Now().Add(time.Hour).Unix()) + i
tempID := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
permID := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: permID, UserID: userID, Layer: tt.permLayer,
}); err != nil {
t.Fatalf("bind permanent authorization: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE auth_keys SET layer = $2, layer_observation_id = $3
WHERE auth_key_id = $1`, authKeyIDToInt64(tempID), tt.tempLayer, tt.tempObs); err != nil {
t.Fatalf("seed temporary layer observation: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE auth_keys SET layer = $2, layer_observation_id = $3
WHERE auth_key_id = $1`, authKeyIDToInt64(permID), tt.permLayer, tt.permObs); err != nil {
t.Fatalf("seed permanent layer observation: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE authorizations SET layer = $2 WHERE auth_key_id = $1`, authKeyIDToInt64(permID), tt.permLayer); err != nil {
t.Fatalf("seed authorization layer mirror: %v", err)
}
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: tempID,
PermAuthKeyID: authKeyIDToInt64(permID),
Nonce: int64(1_000 + i),
TempSessionID: int64(2_000 + i),
ExpiresAt: handshakeExpiry,
EncryptedMessage: []byte("layer merge proof"),
}
err := bindings.Save(ctx, binding)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("bind error = %v, want %v", err, tt.wantErr)
}
if _, found, getErr := bindings.GetByTemp(ctx, tempID); getErr != nil || found {
t.Fatalf("conflicting binding found=%v err=%v, want absent", found, getErr)
}
assertTempIdentityLayerTuple(t, ctx, pool, tempID, tt.tempLayer, tt.tempObs)
assertTempIdentityLayerTuple(t, ctx, pool, permID, tt.permLayer, tt.permObs)
assertTempIdentityAuthorizationLayer(t, ctx, pool, permID, tt.permLayer)
return
}
if err != nil {
t.Fatalf("bind: %v", err)
}
// A normalized proof replay is idempotent and repeats the same merge.
binding.Nonce++
if err := bindings.Save(ctx, binding); err != nil {
t.Fatalf("replay merged binding: %v", err)
}
assertTempIdentityLayerTuple(t, ctx, pool, tempID, tt.wantLayer, tt.wantObs)
assertTempIdentityLayerTuple(t, ctx, pool, permID, tt.wantLayer, tt.wantObs)
assertTempIdentityAuthorizationLayer(t, ctx, pool, permID, tt.wantLayer)
})
}
}
func TestTempAuthKeyBindingStoreConcurrentFirstBindKeepsHandshakeExpiryPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -303,131 +386,6 @@ func TestTempAuthKeyBindingConcurrentWithPermanentDeleteLeavesNoDanglingStatePos
}
}
func TestAuthKeyStoreDeleteRetriesDeterministicPermanentBindingFKRacePostgres(t *testing.T) {
pool := testPool(t)
testCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
keys := NewAuthKeyStore(pool)
bindings := NewTempAuthKeyBindingStore(pool)
auths := NewAuthorizationStore(pool)
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
temp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, handshakeExpiry)
perm := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
userID := createRevokeTestUser(t, testCtx, pool, "deterministic-bind-delete-race")
if err := auths.Bind(testCtx, domain.Authorization{
AuthKeyID: perm,
UserID: userID,
Hash: 9401,
}); err != nil {
t.Fatalf("bind permanent authorization: %v", err)
}
candidate := domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 901,
TempSessionID: 902,
ExpiresAt: handshakeExpiry,
EncryptedMessage: []byte("deterministic FK retry barrier"),
}
deleteConn, err := pool.Acquire(testCtx)
if err != nil {
t.Fatalf("acquire dedicated delete connection: %v", err)
}
defer deleteConn.Release()
var deletePID int
if err := deleteConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&deletePID); err != nil {
t.Fatalf("get delete backend pid: %v", err)
}
blocker, err := pool.Begin(testCtx)
if err != nil {
t.Fatalf("begin key-share blocker: %v", err)
}
defer func() { _ = blocker.Rollback(context.Background()) }()
var lockedPermID int64
if err := blocker.QueryRow(testCtx, `
SELECT auth_key_id
FROM auth_keys
WHERE auth_key_id = $1
FOR KEY SHARE`, authKeyIDToInt64(perm)).Scan(&lockedPermID); err != nil {
t.Fatalf("lock permanent key FOR KEY SHARE: %v", err)
}
if lockedPermID != authKeyIDToInt64(perm) {
t.Fatalf("locked permanent key = %d, want %d", lockedPermID, authKeyIDToInt64(perm))
}
observedDeleteDB := &permanentKeyFKRetryObservingDB{Conn: deleteConn}
deleteResult := make(chan error, 1)
go func() {
deleteResult <- NewAuthKeyStore(observedDeleteDB).Delete(testCtx, perm)
}()
waitForPostgresBackendLockWait(t, testCtx, pool, deletePID)
// This transaction already owns the compatible KEY SHARE lock needed by the
// FK check, so it can commit a new binding while the first DELETE statement
// remains blocked with a snapshot that cannot see that binding.
if err := NewTempAuthKeyBindingStore(blocker).Save(testCtx, candidate); err != nil {
t.Fatalf("save binding behind delete snapshot barrier: %v", err)
}
if err := blocker.Commit(testCtx); err != nil {
t.Fatalf("commit binding and release delete blocker: %v", err)
}
select {
case err := <-deleteResult:
if err != nil {
t.Fatalf("delete after deterministic FK retry: %v", err)
}
case <-testCtx.Done():
t.Fatalf("delete did not finish after releasing FK barrier: %v", testCtx.Err())
}
if observedDeleteDB.attempts != 2 || observedDeleteDB.fkViolations != 1 {
t.Fatalf(
"delete attempts/FK violations = %d/%d, want 2/1",
observedDeleteDB.attempts,
observedDeleteDB.fkViolations,
)
}
if _, found, err := bindings.GetByTemp(testCtx, temp); err != nil || found {
t.Fatalf("binding after deterministic retry found=%v err=%v, want absent", found, err)
}
assertTempIdentityAuthKeyMissing(t, testCtx, keys, temp)
assertTempIdentityAuthKeyMissing(t, testCtx, keys, perm)
assertRevokeTestNoAuthorization(t, testCtx, auths, perm)
}
type permanentKeyFKRetryObservingDB struct {
*pgxpool.Conn
attempts int
fkViolations int
}
func (db *permanentKeyFKRetryObservingDB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
db.attempts++
return &permanentKeyFKRetryObservingRow{Row: db.Conn.QueryRow(ctx, sql, args...), db: db}
}
func (db *permanentKeyFKRetryObservingDB) observeFKViolation(err error) {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == tempAuthKeyPermFKConstraint {
db.fkViolations++
}
}
type permanentKeyFKRetryObservingRow struct {
pgx.Row
db *permanentKeyFKRetryObservingDB
}
func (row *permanentKeyFKRetryObservingRow) Scan(dest ...any) error {
err := row.Row.Scan(dest...)
row.db.observeFKViolation(err)
return err
}
func waitForPostgresBackendLockWait(
t *testing.T,
ctx context.Context,
@ -552,3 +510,47 @@ func assertTempIdentityAuthKeyMissing(
t.Fatalf("auth key %x found=%v err=%v, want absent", id, found, err)
}
}
func assertTempIdentityLayerTuple(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
authKeyID [8]byte,
wantLayer int,
wantObservationID int64,
) {
t.Helper()
var (
layer int
observationID int64
)
if err := pool.QueryRow(ctx, `
SELECT layer, layer_observation_id
FROM auth_keys
WHERE auth_key_id = $1`, authKeyIDToInt64(authKeyID)).Scan(&layer, &observationID); err != nil {
t.Fatalf("read auth-key layer tuple: %v", err)
}
if layer != wantLayer || observationID != wantObservationID {
t.Fatalf("auth-key layer tuple = (%d,%d), want (%d,%d)", layer, observationID, wantLayer, wantObservationID)
}
}
func assertTempIdentityAuthorizationLayer(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
authKeyID [8]byte,
wantLayer int,
) {
t.Helper()
var layer int
if err := pool.QueryRow(ctx, `
SELECT layer
FROM authorizations
WHERE auth_key_id = $1`, authKeyIDToInt64(authKeyID)).Scan(&layer); err != nil {
t.Fatalf("read authorization layer mirror: %v", err)
}
if layer != wantLayer {
t.Fatalf("authorization layer mirror = %d, want %d", layer, wantLayer)
}
}

View file

@ -55,16 +55,39 @@ func (s *UpdateStateStore) Save(ctx context.Context, id [8]byte, userID int64, s
return nil
}
func (s *UpdateStateStore) CommitDeliveredState(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState, mode domain.UpdateStateCommitMode) error {
establishObserved := mode == domain.UpdateStateCommitDeliveredAndObservedBaseline
if mode != domain.UpdateStateCommitDeliveredOnly && !establishObserved {
return fmt.Errorf("commit delivered update state: invalid mode %d", mode)
}
if _, err := s.db.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $7 THEN $3 ELSE 0 END)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = GREATEST(update_states.pts, EXCLUDED.pts),
qts = GREATEST(update_states.qts, EXCLUDED.qts),
date = GREATEST(update_states.date, EXCLUDED.date),
seq = GREATEST(update_states.seq, EXCLUDED.seq),
observed_pts = CASE
WHEN $7 THEN GREATEST(update_states.observed_pts, EXCLUDED.observed_pts)
ELSE update_states.observed_pts
END,
updated_at = now()`, authKeyIDToInt64(id), userID, st.Pts, st.Qts, st.Date, st.Seq, establishObserved); err != nil {
return fmt.Errorf("commit delivered update state: %w", err)
}
return nil
}
func (s *UpdateStateStore) ObserveClientState(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
if st.Pts < 0 {
st.Pts = 0
}
if _, err := s.db.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
VALUES ($1, $2, $3, $4, $5, $6, $3)
VALUES ($1, $2, 0, 0, 0, 0, $3)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
observed_pts = GREATEST(update_states.observed_pts, EXCLUDED.observed_pts),
updated_at = now()`, authKeyIDToInt64(id), userID, st.Pts, st.Qts, st.Date, st.Seq); err != nil {
updated_at = now()`, authKeyIDToInt64(id), userID, st.Pts); err != nil {
return fmt.Errorf("observe client update state: %w", err)
}
return nil

View file

@ -0,0 +1,79 @@
package postgres
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
)
type captureUpdateStateDB struct {
sql string
args []any
}
func (d *captureUpdateStateDB) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
d.sql = sql
d.args = append([]any(nil), args...)
return pgconn.NewCommandTag("INSERT 0 1"), nil
}
func (*captureUpdateStateDB) Query(context.Context, string, ...any) (pgx.Rows, error) {
panic("unexpected Query")
}
func (*captureUpdateStateDB) QueryRow(context.Context, string, ...any) pgx.Row {
panic("unexpected QueryRow")
}
func TestCommitDeliveredStateUsesOneAtomicBaselineUpsert(t *testing.T) {
db := &captureUpdateStateDB{}
store := NewUpdateStateStore(db)
state := domain.UpdateState{Pts: 9, Qts: 2, Date: 90, Seq: 3}
if err := store.CommitDeliveredState(context.Background(), [8]byte{4}, 1004, state, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
t.Fatalf("commit baseline: %v", err)
}
if len(db.args) != 7 || db.args[6] != true {
t.Fatalf("baseline args = %#v, want final true mode", db.args)
}
for _, fragment := range []string{
"pts = GREATEST(update_states.pts, EXCLUDED.pts)",
"WHEN $7 THEN GREATEST(update_states.observed_pts, EXCLUDED.observed_pts)",
} {
if !strings.Contains(db.sql, fragment) {
t.Fatalf("atomic commit SQL missing %q:\n%s", fragment, db.sql)
}
}
}
func TestCommitDeliveredOnlyLeavesObservedUntouched(t *testing.T) {
db := &captureUpdateStateDB{}
store := NewUpdateStateStore(db)
if err := store.CommitDeliveredState(context.Background(), [8]byte{5}, 1005, domain.UpdateState{Pts: 7}, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("commit delivered-only: %v", err)
}
if len(db.args) != 7 || db.args[6] != false {
t.Fatalf("delivered-only args = %#v, want final false mode", db.args)
}
if !strings.Contains(db.sql, "ELSE update_states.observed_pts") {
t.Fatalf("delivered-only SQL can overwrite observed:\n%s", db.sql)
}
}
func TestObserveClientStateDoesNotFabricateConfirmedCursor(t *testing.T) {
db := &captureUpdateStateDB{}
store := NewUpdateStateStore(db)
if err := store.ObserveClientState(context.Background(), [8]byte{6}, 1006, domain.UpdateState{Pts: 11, Qts: 4, Date: 110, Seq: 2}); err != nil {
t.Fatalf("observe request: %v", err)
}
if !strings.Contains(db.sql, "VALUES ($1, $2, 0, 0, 0, 0, $3)") {
t.Fatalf("observed-only insert fabricated confirmed values:\n%s", db.sql)
}
if len(db.args) != 3 || db.args[2] != 11 {
t.Fatalf("observed-only args = %#v, want auth/user/pts", db.args)
}
}

View file

@ -10,9 +10,14 @@ import (
type UpdateStateStore interface {
Get(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error)
Save(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState) error
// CommitDeliveredState atomically advances the cursor proven by one physically
// delivered response. Baseline mode advances confirmed and observed together;
// delivered-only mode must leave observed untouched.
CommitDeliveredState(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState, mode domain.UpdateStateCommitMode) error
// ObserveClientState advances only the state that the client has proved it already owns by
// carrying it in a request (or by explicitly establishing a getState snapshot baseline).
// Durable-log retention must use this watermark, never a response state merely sent by server.
// carrying it in a request. An audited getState baseline advances the same watermark only via
// the atomic CommitDeliveredState baseline mode after physical delivery. Durable-log retention
// must use this watermark, never a response state merely sent by server.
ObserveClientState(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState) error
Delete(ctx context.Context, authKeyID [8]byte, userID int64) error
DeleteAuthKey(ctx context.Context, authKeyID [8]byte) error