package service import ( "crypto/rand" "encoding/hex" "errors" "fmt" "strings" "github.com/ez-api/ez-api/internal/model" "github.com/ez-api/foundation/tokenhash" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) var ( ErrMasterNotFound = errors.New("master not found") ErrMasterNotActive = errors.New("master is not active") ErrChildKeyLimitReached = errors.New("child key limit reached") ErrChildKeyGroupForbidden = errors.New("cannot issue key for a different group") ) type MasterService struct { db *gorm.DB } func NewMasterService(db *gorm.DB) *MasterService { return &MasterService{db: db} } func (s *MasterService) CreateMaster(name, group string, maxChildKeys, globalQPS int) (*model.Master, string, error) { rawMasterKey, err := generateRandomKey(32) if err != nil { return nil, "", fmt.Errorf("failed to generate master key: %w", err) } hashedMasterKey, err := bcrypt.GenerateFromPassword([]byte(rawMasterKey), bcrypt.DefaultCost) if err != nil { return nil, "", fmt.Errorf("failed to hash master key: %w", err) } masterKeyDigest := tokenhash.HashToken(rawMasterKey) master := &model.Master{ Name: name, MasterKey: string(hashedMasterKey), MasterKeyDigest: masterKeyDigest, Group: group, DefaultNamespace: "default", Namespaces: "default", MaxChildKeys: maxChildKeys, GlobalQPS: globalQPS, Status: "active", Epoch: 1, } if err := s.db.Create(master).Error; err != nil { return nil, "", err } return master, rawMasterKey, nil } func (s *MasterService) ValidateMasterKey(masterKey string) (*model.Master, error) { digest := tokenhash.HashToken(masterKey) var master model.Master if err := s.db.Where("master_key_digest = ?", digest).First(&master).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } // Backward compatibility: look for legacy rows without digest. var masters []model.Master if err := s.db.Where("master_key_digest = '' OR master_key_digest IS NULL").Find(&masters).Error; err != nil { return nil, err } for _, m := range masters { if bcrypt.CompareHashAndPassword([]byte(m.MasterKey), []byte(masterKey)) == nil { master = m // Opportunistically backfill digest for next time. if strings.TrimSpace(m.MasterKeyDigest) == "" { _ = s.db.Model(&m).Update("master_key_digest", digest).Error } goto verified } } return nil, errors.New("invalid master key") } if bcrypt.CompareHashAndPassword([]byte(master.MasterKey), []byte(masterKey)) != nil { return nil, errors.New("invalid master key") } verified: if master.Status != "active" { return nil, fmt.Errorf("master is not active") } return &master, nil } func (s *MasterService) IssueChildKey(masterID uint, group string, scopes string) (*model.Key, string, error) { return s.issueChildKey(masterID, group, scopes, "master") } func (s *MasterService) IssueChildKeyAsAdmin(masterID uint, group string, scopes string) (*model.Key, string, error) { return s.issueChildKey(masterID, group, scopes, "admin") } func (s *MasterService) issueChildKey(masterID uint, group string, scopes string, issuedBy string) (*model.Key, string, error) { var master model.Master if err := s.db.First(&master, masterID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, "", fmt.Errorf("%w: %d", ErrMasterNotFound, masterID) } return nil, "", fmt.Errorf("load master: %w", err) } if master.Status != "active" { return nil, "", fmt.Errorf("%w", ErrMasterNotActive) } group = strings.TrimSpace(group) if group == "" { group = master.Group } if group != master.Group { return nil, "", fmt.Errorf("%w", ErrChildKeyGroupForbidden) } var count int64 s.db.Model(&model.Key{}).Where("master_id = ?", masterID).Count(&count) if count >= int64(master.MaxChildKeys) { return nil, "", fmt.Errorf("%w for master %d", ErrChildKeyLimitReached, masterID) } rawChildKey, err := generateRandomKey(32) if err != nil { return nil, "", fmt.Errorf("failed to generate child key: %w", err) } tokenHash := tokenhash.HashToken(rawChildKey) hashedChildKey, err := bcrypt.GenerateFromPassword([]byte(rawChildKey), bcrypt.DefaultCost) if err != nil { return nil, "", fmt.Errorf("failed to hash child key: %w", err) } key := &model.Key{ MasterID: masterID, KeySecret: string(hashedChildKey), TokenHash: tokenHash, Group: group, Scopes: scopes, DefaultNamespace: strings.TrimSpace(master.DefaultNamespace), Namespaces: strings.TrimSpace(master.Namespaces), IssuedAtEpoch: master.Epoch, Status: "active", IssuedBy: strings.TrimSpace(issuedBy), } if strings.TrimSpace(key.DefaultNamespace) == "" { key.DefaultNamespace = "default" } if strings.TrimSpace(key.Namespaces) == "" { key.Namespaces = key.DefaultNamespace } if key.IssuedBy == "" { key.IssuedBy = "master" } if err := s.db.Create(key).Error; err != nil { return nil, "", err } return key, rawChildKey, nil } func generateRandomKey(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil }