package domain import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "sync" "time" ) type SteamTicket struct { TicketID string SteamID string AppID uint64 ExpiresAt time.Time } type VerifiedIdentity struct { PlayerID string SteamID string } type TicketVerifier struct { mu sync.Mutex expectedApp uint64 consumed map[string]time.Time } var ( ErrTicketRejected = fmt.Errorf("steam ticket rejected") ErrSessionRejected = fmt.Errorf("session rejected") ) func NewTicketVerifier(expectedApp uint64) (*TicketVerifier, error) { if expectedApp == 0 { return nil, ErrTicketRejected } return &TicketVerifier{expectedApp: expectedApp, consumed: make(map[string]time.Time)}, nil } // Verify consumes a backend-validated ticket exactly once. In production the // adapter must obtain the Steam Web API response before calling this policy; // callers never get to choose the verified SteamID independently. func (v *TicketVerifier) Verify(ticket SteamTicket, resolve func(string) (string, bool), now time.Time) (VerifiedIdentity, error) { v.mu.Lock() defer v.mu.Unlock() if ticket.TicketID == "" || ticket.SteamID == "" || resolve == nil || ticket.AppID != v.expectedApp || ticket.ExpiresAt.IsZero() || !now.Before(ticket.ExpiresAt) { return VerifiedIdentity{}, ErrTicketRejected } if _, used := v.consumed[ticket.TicketID]; used { return VerifiedIdentity{}, ErrTicketRejected } playerID, ok := resolve(ticket.SteamID) if !ok || playerID == "" { return VerifiedIdentity{}, ErrTicketRejected } v.consumed[ticket.TicketID] = now return VerifiedIdentity{PlayerID: playerID, SteamID: ticket.SteamID}, nil } type Session struct { SessionID string PlayerID string ExpiresAt time.Time RevokedAt time.Time } type SessionStore struct { mu sync.Mutex sessions map[string]Session digests map[string]string } func NewSessionStore() *SessionStore { return &SessionStore{sessions: make(map[string]Session), digests: make(map[string]string)} } func (s *SessionStore) Issue(playerID string, lifetime time.Duration, now time.Time) (Session, string, error) { if playerID == "" || lifetime <= 0 { return Session{}, "", ErrSessionRejected } token, err := randomToken() if err != nil { return Session{}, "", err } sessionID, err := randomToken() if err != nil { return Session{}, "", err } session := Session{SessionID: sessionID, PlayerID: playerID, ExpiresAt: now.Add(lifetime)} s.mu.Lock() s.sessions[sessionID] = session s.digests[sessionID] = digestToken(token) s.mu.Unlock() return session, token, nil } func (s *SessionStore) Authenticate(sessionID, token string, now time.Time) (Session, error) { s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[sessionID] if !ok || session.RevokedAt != (time.Time{}) || !now.Before(session.ExpiresAt) || !constantTimeEqual(s.digests[sessionID], digestToken(token)) { return Session{}, ErrSessionRejected } return session, nil } func (s *SessionStore) Revoke(sessionID string, now time.Time) error { s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[sessionID] if !ok { return ErrSessionRejected } if session.RevokedAt.IsZero() { session.RevokedAt = now s.sessions[sessionID] = session } return nil } func randomToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } func digestToken(token string) string { digest := sha256.Sum256([]byte(token)) return hex.EncodeToString(digest[:]) } func constantTimeEqual(a, b string) bool { if len(a) != len(b) { return false } return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 }