feat: sync multilayer td integration
This commit is contained in:
parent
20a310f6ca
commit
766c5db992
491 changed files with 26235 additions and 35340 deletions
|
|
@ -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)。不存在时静默成功。
|
||||
|
|
|
|||
85
internal/store/authkey_session_layer.go
Normal file
85
internal/store/authkey_session_layer.go
Normal 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
|
||||
}
|
||||
72
internal/store/authkey_test.go
Normal file
72
internal/store/authkey_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
163
internal/store/memory/authkey_session_layer.go
Normal file
163
internal/store/memory/authkey_session_layer.go
Normal 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
|
||||
}
|
||||
235
internal/store/memory/authkey_session_layer_test.go
Normal file
235
internal/store/memory/authkey_session_layer_test.go
Normal 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)
|
||||
}
|
||||
65
internal/store/memory/update_state_delivery_test.go
Normal file
65
internal/store/memory/update_state_delivery_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
222
internal/store/postgres/auth_identity_lock.go
Normal file
222
internal/store/postgres/auth_identity_lock.go
Normal 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
|
||||
}
|
||||
429
internal/store/postgres/auth_identity_lock_integration_test.go
Normal file
429
internal/store/postgres/auth_identity_lock_integration_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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(¤tLayer, &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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
245
internal/store/postgres/authkey_session_layer.go
Normal file
245
internal/store/postgres/authkey_session_layer.go
Normal 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(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.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(¤t.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(¤t.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(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(¤tPermID)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
internal/store/postgres/updatestate_delivery_test.go
Normal file
79
internal/store/postgres/updatestate_delivery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue