feat: sync multilayer td integration

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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