You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
155 lines
3.7 KiB
155 lines
3.7 KiB
|
2 months ago
|
package directstream
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
TicketEntropyBytes = 32
|
||
|
|
defaultTicketTTL = 30 * time.Second
|
||
|
|
defaultMaxPending = 256
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrInvalidTicketClaims = errors.New("invalid direct stream ticket claims")
|
||
|
|
ErrInvalidTicket = errors.New("invalid or expired direct stream ticket")
|
||
|
|
ErrTicketCapacity = errors.New("direct stream ticket capacity reached")
|
||
|
|
)
|
||
|
|
|
||
|
|
type TicketClaims struct {
|
||
|
|
SessionID string
|
||
|
|
Channel string
|
||
|
|
Actor string
|
||
|
|
Access string
|
||
|
|
}
|
||
|
|
|
||
|
|
type TicketBinding struct {
|
||
|
|
SessionID string
|
||
|
|
Channel string
|
||
|
|
}
|
||
|
|
|
||
|
|
type IssuedTicket struct {
|
||
|
|
Value string
|
||
|
|
ExpiresAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type TicketStoreOptions struct {
|
||
|
|
Clock func() time.Time
|
||
|
|
Entropy io.Reader
|
||
|
|
TTL time.Duration
|
||
|
|
MaxPending int
|
||
|
|
}
|
||
|
|
|
||
|
|
type ticketRecord struct {
|
||
|
|
claims TicketClaims
|
||
|
|
expiresAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type TicketStore struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
clock func() time.Time
|
||
|
|
entropy io.Reader
|
||
|
|
ttl time.Duration
|
||
|
|
maxPending int
|
||
|
|
records map[[sha256.Size]byte]ticketRecord
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewTicketStore(options TicketStoreOptions) *TicketStore {
|
||
|
|
clock := options.Clock
|
||
|
|
if clock == nil {
|
||
|
|
clock = time.Now
|
||
|
|
}
|
||
|
|
entropy := options.Entropy
|
||
|
|
if entropy == nil {
|
||
|
|
entropy = rand.Reader
|
||
|
|
}
|
||
|
|
ttl := options.TTL
|
||
|
|
if ttl <= 0 {
|
||
|
|
ttl = defaultTicketTTL
|
||
|
|
}
|
||
|
|
maxPending := options.MaxPending
|
||
|
|
if maxPending <= 0 {
|
||
|
|
maxPending = defaultMaxPending
|
||
|
|
}
|
||
|
|
return &TicketStore{
|
||
|
|
clock: clock,
|
||
|
|
entropy: entropy,
|
||
|
|
ttl: ttl,
|
||
|
|
maxPending: maxPending,
|
||
|
|
records: make(map[[sha256.Size]byte]ticketRecord),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *TicketStore) Issue(claims TicketClaims) (IssuedTicket, error) {
|
||
|
|
if err := validateTicketClaims(claims); err != nil {
|
||
|
|
return IssuedTicket{}, err
|
||
|
|
}
|
||
|
|
now := s.clock().UTC()
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
s.pruneExpiredLocked(now)
|
||
|
|
if len(s.records) >= s.maxPending {
|
||
|
|
return IssuedTicket{}, ErrTicketCapacity
|
||
|
|
}
|
||
|
|
|
||
|
|
raw := make([]byte, TicketEntropyBytes)
|
||
|
|
if _, err := io.ReadFull(s.entropy, raw); err != nil {
|
||
|
|
return IssuedTicket{}, fmt.Errorf("generate direct stream ticket: %w", err)
|
||
|
|
}
|
||
|
|
value := base64.RawURLEncoding.EncodeToString(raw)
|
||
|
|
digest := sha256.Sum256([]byte(value))
|
||
|
|
if _, exists := s.records[digest]; exists {
|
||
|
|
return IssuedTicket{}, errors.New("generate direct stream ticket: entropy collision")
|
||
|
|
}
|
||
|
|
expiresAt := now.Add(s.ttl)
|
||
|
|
s.records[digest] = ticketRecord{claims: claims, expiresAt: expiresAt}
|
||
|
|
return IssuedTicket{Value: value, ExpiresAt: expiresAt}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *TicketStore) Consume(value string, binding TicketBinding) (TicketClaims, error) {
|
||
|
|
if value == "" || binding.SessionID == "" || ValidateChannel(binding.Channel) != nil {
|
||
|
|
return TicketClaims{}, ErrInvalidTicket
|
||
|
|
}
|
||
|
|
raw, err := base64.RawURLEncoding.DecodeString(value)
|
||
|
|
if err != nil || len(raw) != TicketEntropyBytes {
|
||
|
|
return TicketClaims{}, ErrInvalidTicket
|
||
|
|
}
|
||
|
|
digest := sha256.Sum256([]byte(value))
|
||
|
|
now := s.clock().UTC()
|
||
|
|
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
record, ok := s.records[digest]
|
||
|
|
if !ok || !now.Before(record.expiresAt) {
|
||
|
|
delete(s.records, digest)
|
||
|
|
return TicketClaims{}, ErrInvalidTicket
|
||
|
|
}
|
||
|
|
if record.claims.SessionID != binding.SessionID || record.claims.Channel != binding.Channel {
|
||
|
|
return TicketClaims{}, ErrInvalidTicket
|
||
|
|
}
|
||
|
|
delete(s.records, digest)
|
||
|
|
return record.claims, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *TicketStore) pruneExpiredLocked(now time.Time) {
|
||
|
|
for digest, record := range s.records {
|
||
|
|
if !now.Before(record.expiresAt) {
|
||
|
|
delete(s.records, digest)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateTicketClaims(claims TicketClaims) error {
|
||
|
|
if claims.SessionID == "" || claims.Actor == "" || ValidateChannel(claims.Channel) != nil || ValidateAccess(claims.Access) != nil {
|
||
|
|
return ErrInvalidTicketClaims
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|