Files
zarinpal-analytics-backend/internal/analytics/cursor.go
Amirhossein Khalili d6de48fd15 fix: harden terminal-scoped analytics contracts
Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
2026-08-20 21:29:18 +03:30

109 lines
3.4 KiB
Go

package analytics
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"time"
)
var ErrInvalidCursor = errors.New("analytics: invalid cursor")
type detailCursorPosition struct {
CreatedAt time.Time
SessionID int64
}
type detailCursorPayload struct {
Version int `json:"v"`
Binding string `json:"b"`
CreatedAt time.Time `json:"createdAt"`
SessionID int64 `json:"sessionId"`
ExpiresAt time.Time `json:"expiresAt"`
}
type detailCursorBinding struct {
MerchantID int64 `json:"merchantId"`
PeriodID string `json:"periodId"`
View string `json:"view"`
FailureStage string `json:"failureStage"`
TerminalID string `json:"terminalId"`
Status string `json:"status"`
Minimum *int64 `json:"minimum"`
Maximum *int64 `json:"maximum"`
}
func (s *Service) encodeDetailCursor(merchantID int64, period Period, filter DetailFilter, createdAt time.Time, sessionID int64) (string, error) {
payload := detailCursorPayload{
Version: 1,
Binding: detailCursorBindingDigest(merchantID, period, filter),
CreatedAt: createdAt.UTC(),
SessionID: sessionID,
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
aead, err := s.cursorAEAD()
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := aead.Seal(nonce, nonce, encoded, nil)
return base64.RawURLEncoding.EncodeToString(sealed), nil
}
func (s *Service) decodeDetailCursor(merchantID int64, period Period, filter DetailFilter, cursor string) (detailCursorPosition, error) {
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return detailCursorPosition{}, ErrInvalidCursor
}
aead, err := s.cursorAEAD()
if err != nil || len(sealed) < aead.NonceSize() {
return detailCursorPosition{}, ErrInvalidCursor
}
nonce, ciphertext := sealed[:aead.NonceSize()], sealed[aead.NonceSize():]
payloadBytes, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return detailCursorPosition{}, ErrInvalidCursor
}
var payload detailCursorPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil || payload.Version != 1 || payload.SessionID <= 0 || payload.CreatedAt.IsZero() || time.Now().UTC().After(payload.ExpiresAt) {
return detailCursorPosition{}, ErrInvalidCursor
}
wantBinding := detailCursorBindingDigest(merchantID, period, filter)
if payload.Binding != wantBinding {
return detailCursorPosition{}, ErrInvalidCursor
}
return detailCursorPosition{CreatedAt: payload.CreatedAt.UTC(), SessionID: payload.SessionID}, nil
}
func (s *Service) cursorAEAD() (cipher.AEAD, error) {
key := sha256.Sum256(s.cursorSecret)
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func detailCursorBindingDigest(merchantID int64, period Period, filter DetailFilter) string {
binding := detailCursorBinding{
MerchantID: merchantID, PeriodID: period.PeriodID, View: filter.View,
FailureStage: filter.FailureStage, TerminalID: filter.TerminalID, Status: filter.Status,
Minimum: filter.MinAmountRial, Maximum: filter.MaxAmountRial,
}
encoded, _ := json.Marshal(binding)
digest := sha256.Sum256(encoded)
return base64.RawURLEncoding.EncodeToString(digest[:])
}