Pulse/internal/license/license.go

480 lines
14 KiB
Go

package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Public key for license validation (Ed25519).
// This will be embedded at build time or set via SetPublicKey.
// For development, leave empty to skip validation.
var publicKey ed25519.PublicKey
// SetPublicKey sets the public key for license validation.
// This should be called during initialization with the production key.
func SetPublicKey(key ed25519.PublicKey) {
publicKey = key
}
// License errors
var (
ErrInvalidLicense = errors.New("invalid license key")
ErrExpiredLicense = errors.New("license has expired")
ErrMalformedLicense = errors.New("malformed license key")
ErrSignatureInvalid = errors.New("license signature invalid")
ErrFeatureNotIncluded = errors.New("feature not included in license")
ErrNoPublicKey = errors.New("no public key configured for validation")
ErrNoLicense = errors.New("no license activated")
)
// Claims represents the JWT claims in a Pulse Pro license.
type Claims struct {
// License ID (unique identifier)
LicenseID string `json:"lid"`
// Email of the license holder
Email string `json:"email"`
// License tier (pro, pro_annual, lifetime, msp, enterprise)
Tier Tier `json:"tier"`
// Issued at (Unix timestamp)
IssuedAt int64 `json:"iat"`
// Expires at (Unix timestamp, 0 for lifetime)
ExpiresAt int64 `json:"exp,omitempty"`
// Features explicitly granted (optional, tier implies features)
Features []string `json:"features,omitempty"`
// Max nodes (0 = unlimited)
MaxNodes int `json:"max_nodes,omitempty"`
// Max guests (0 = unlimited)
MaxGuests int `json:"max_guests,omitempty"`
}
// License represents a validated Pulse Pro license.
type License struct {
// Raw JWT token
Raw string `json:"-"`
// Validated claims
Claims Claims `json:"claims"`
// Validation metadata
ValidatedAt time.Time `json:"validated_at"`
// Grace period end (if license was validated during grace period)
GracePeriodEnd *time.Time `json:"grace_period_end,omitempty"`
}
// IsExpired checks if the license has expired.
func (l *License) IsExpired() bool {
if l.Claims.ExpiresAt == 0 {
return false // Lifetime license never expires
}
return time.Now().Unix() > l.Claims.ExpiresAt
}
// IsLifetime returns true if this is a lifetime license.
func (l *License) IsLifetime() bool {
return l.Claims.ExpiresAt == 0 || l.Claims.Tier == TierLifetime
}
// DaysRemaining returns the number of days until expiration.
// Returns -1 for lifetime licenses.
func (l *License) DaysRemaining() int {
if l.IsLifetime() {
return -1
}
remaining := time.Until(time.Unix(l.Claims.ExpiresAt, 0))
if remaining < 0 {
return 0
}
return int(remaining.Hours() / 24)
}
// ExpiresAt returns the expiration time, or nil for lifetime.
func (l *License) ExpiresAt() *time.Time {
if l.IsLifetime() {
return nil
}
t := time.Unix(l.Claims.ExpiresAt, 0)
return &t
}
// HasFeature checks if the license grants a specific feature.
func (l *License) HasFeature(feature string) bool {
// Check explicit feature list first
for _, f := range l.Claims.Features {
if f == feature {
return true
}
}
// Fall back to tier-based features
return TierHasFeature(l.Claims.Tier, feature)
}
// AllFeatures returns all features granted by this license.
func (l *License) AllFeatures() []string {
// Start with tier features
features := make(map[string]bool)
for _, f := range TierFeatures[l.Claims.Tier] {
features[f] = true
}
// Add explicit features
for _, f := range l.Claims.Features {
features[f] = true
}
// Convert to slice
result := make([]string, 0, len(features))
for f := range features {
result = append(result, f)
}
return result
}
// Service manages license validation and feature gating.
type Service struct {
mu sync.RWMutex
license *License
// Grace period duration when license validation fails
gracePeriod time.Duration
// Callback when license changes
onLicenseChange func(*License)
}
// NewService creates a new license service.
func NewService() *Service {
return &Service{
gracePeriod: 7 * 24 * time.Hour, // 7 days grace period
}
}
// SetLicenseChangeCallback sets a callback for license change events.
func (s *Service) SetLicenseChangeCallback(cb func(*License)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onLicenseChange = cb
}
// Activate validates and activates a license key.
func (s *Service) Activate(licenseKey string) (*License, error) {
license, err := ValidateLicense(licenseKey)
if err != nil {
return nil, err
}
s.mu.Lock()
s.license = license
cb := s.onLicenseChange
s.mu.Unlock()
if cb != nil {
cb(license)
}
return license, nil
}
// Clear removes the current license.
func (s *Service) Clear() {
s.mu.Lock()
s.license = nil
cb := s.onLicenseChange
s.mu.Unlock()
if cb != nil {
cb(nil)
}
}
// Current returns the current license, or nil if none.
func (s *Service) Current() *License {
s.mu.RLock()
defer s.mu.RUnlock()
return s.license
}
// IsValid returns true if a valid, non-expired license is active.
func (s *Service) IsValid() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.license == nil {
return false
}
if s.license.IsExpired() {
// Check grace period
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
return true
}
return false
}
return true
}
// HasFeature checks if the current license grants a feature.
func (s *Service) HasFeature(feature string) bool {
s.mu.Lock() // Need write lock since we may update grace period
defer s.mu.Unlock()
if s.license == nil {
return false
}
if s.license.IsExpired() {
// If license just expired and grace period not yet set, set it now
if s.license.GracePeriodEnd == nil {
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
s.license.GracePeriodEnd = &gracePeriodEnd
}
// Check grace period - still allow features during grace
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
return s.license.HasFeature(feature)
}
return false
}
return s.license.HasFeature(feature)
}
// LicenseState represents the current state of the license
type LicenseState string
const (
LicenseStateNone LicenseState = "none"
LicenseStateActive LicenseState = "active"
LicenseStateExpired LicenseState = "expired"
LicenseStateGracePeriod LicenseState = "grace_period"
)
// GetLicenseState returns the current license state and the license itself
func (s *Service) GetLicenseState() (LicenseState, *License) {
s.mu.Lock()
defer s.mu.Unlock()
if s.license == nil {
return LicenseStateNone, nil
}
if s.license.IsExpired() {
// If license just expired and grace period not yet set, set it now
if s.license.GracePeriodEnd == nil {
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
s.license.GracePeriodEnd = &gracePeriodEnd
}
// Check if within grace period
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
return LicenseStateGracePeriod, s.license
}
return LicenseStateExpired, s.license
}
return LicenseStateActive, s.license
}
// GetLicenseStateString returns the current license state as string and whether features are available
// This implements the LicenseChecker interface for the AI service
func (s *Service) GetLicenseStateString() (string, bool) {
state, _ := s.GetLicenseState()
hasFeatures := state == LicenseStateActive || state == LicenseStateGracePeriod
return string(state), hasFeatures
}
// RequireFeature returns an error if the feature is not available.
// This is the primary method for feature gating.
func (s *Service) RequireFeature(feature string) error {
if !s.HasFeature(feature) {
return fmt.Errorf("%w: %s requires Pulse Pro", ErrFeatureNotIncluded, GetFeatureDisplayName(feature))
}
return nil
}
// Status returns a summary of the current license status.
func (s *Service) Status() *LicenseStatus {
s.mu.Lock() // Need write lock since we may update grace period
defer s.mu.Unlock()
status := &LicenseStatus{
Valid: false,
Tier: TierFree,
}
if s.license == nil {
return status
}
status.Email = s.license.Claims.Email
status.Tier = s.license.Claims.Tier
status.IsLifetime = s.license.IsLifetime()
status.DaysRemaining = s.license.DaysRemaining()
status.Features = s.license.AllFeatures()
status.MaxNodes = s.license.Claims.MaxNodes
status.MaxGuests = s.license.Claims.MaxGuests
if s.license.ExpiresAt() != nil {
exp := s.license.ExpiresAt().Format(time.RFC3339)
status.ExpiresAt = &exp
}
// Check validity - set grace period dynamically if expired
if !s.license.IsExpired() {
status.Valid = true
} else {
// License is expired - ensure grace period is set
if s.license.GracePeriodEnd == nil {
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
s.license.GracePeriodEnd = &gracePeriodEnd
}
// Check if within grace period
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
status.Valid = true
status.InGracePeriod = true
graceEnd := s.license.GracePeriodEnd.Format(time.RFC3339)
status.GracePeriodEnd = &graceEnd
}
}
return status
}
// LicenseStatus is the JSON response for license status API.
type LicenseStatus struct {
Valid bool `json:"valid"`
Tier Tier `json:"tier"`
Email string `json:"email,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
IsLifetime bool `json:"is_lifetime"`
DaysRemaining int `json:"days_remaining"`
Features []string `json:"features"`
MaxNodes int `json:"max_nodes,omitempty"`
MaxGuests int `json:"max_guests,omitempty"`
InGracePeriod bool `json:"in_grace_period,omitempty"`
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
}
// ValidateLicense validates a license key and returns the license if valid.
func ValidateLicense(licenseKey string) (*License, error) {
// Trim whitespace
licenseKey = strings.TrimSpace(licenseKey)
if licenseKey == "" {
return nil, ErrInvalidLicense
}
// Parse JWT (base64url.base64url.base64url)
parts := strings.Split(licenseKey, ".")
if len(parts) != 3 {
return nil, ErrMalformedLicense
}
// Decode header (not used currently, but validate it exists)
_, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("%w: invalid header encoding", ErrMalformedLicense)
}
// Decode payload
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w: invalid payload encoding", ErrMalformedLicense)
}
// Decode signature
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w: invalid signature encoding", ErrMalformedLicense)
}
// Verify signature
// In production, public key MUST be set. In dev mode, we skip signature validation.
devMode := os.Getenv("PULSE_LICENSE_DEV_MODE") == "true"
if len(publicKey) > 0 {
signedData := []byte(parts[0] + "." + parts[1])
if !ed25519.Verify(publicKey, signedData, signature) {
return nil, ErrSignatureInvalid
}
} else if !devMode {
// No public key and not in dev mode - fail validation
return nil, fmt.Errorf("%w: signature verification required", ErrNoPublicKey)
}
// If devMode and no public key, we skip signature verification (for testing only)
// Parse claims
var claims Claims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return nil, fmt.Errorf("%w: invalid claims JSON", ErrMalformedLicense)
}
// Validate required fields
if claims.LicenseID == "" {
return nil, fmt.Errorf("%w: missing license ID", ErrMalformedLicense)
}
if claims.Email == "" {
return nil, fmt.Errorf("%w: missing email", ErrMalformedLicense)
}
if claims.Tier == "" {
return nil, fmt.Errorf("%w: missing tier", ErrMalformedLicense)
}
license := &License{
Raw: licenseKey,
Claims: claims,
ValidatedAt: time.Now(),
}
// Check expiration with grace period support
if license.IsExpired() {
// Calculate how long ago it expired
expirationTime := time.Unix(claims.ExpiresAt, 0)
// Grace period: 7 days after expiration
gracePeriodDuration := 7 * 24 * time.Hour
gracePeriodEnd := expirationTime.Add(gracePeriodDuration)
if time.Now().Before(gracePeriodEnd) {
// Within grace period - allow activation but mark as in grace period
license.GracePeriodEnd = &gracePeriodEnd
// License is still valid during grace period
} else {
// Past grace period - reject
return nil, fmt.Errorf("%w: expired on %s (grace period ended %s)",
ErrExpiredLicense,
expirationTime.Format("2006-01-02"),
gracePeriodEnd.Format("2006-01-02"))
}
}
return license, nil
}
// GenerateLicenseForTesting creates a test license (DO NOT USE IN PRODUCTION).
// This is only for development/testing without a real license server.
func GenerateLicenseForTesting(email string, tier Tier, expiresIn time.Duration) (string, error) {
claims := Claims{
LicenseID: fmt.Sprintf("test_%d", time.Now().UnixNano()),
Email: email,
Tier: tier,
IssuedAt: time.Now().Unix(),
}
if expiresIn > 0 {
claims.ExpiresAt = time.Now().Add(expiresIn).Unix()
}
// Create unsigned JWT (for testing only)
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
payloadBytes, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
// Fake signature (testing only - real licenses need proper signing)
signature := base64.RawURLEncoding.EncodeToString([]byte("test-signature-not-valid"))
return header + "." + payload + "." + signature, nil
}