Pulse/pkg/licensing/service.go

1177 lines
35 KiB
Go

package licensing
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"sort"
"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 (
publicKeyMu sync.RWMutex
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) {
publicKeyMu.Lock()
defer publicKeyMu.Unlock()
if len(key) == 0 {
publicKey = nil
return
}
keyCopy := make(ed25519.PublicKey, len(key))
copy(keyCopy, key)
publicKey = keyCopy
}
func currentPublicKey() ed25519.PublicKey {
publicKeyMu.RLock()
defer publicKeyMu.RUnlock()
if len(publicKey) == 0 {
return nil
}
keyCopy := make(ed25519.PublicKey, len(publicKey))
copy(keyCopy, publicKey)
return keyCopy
}
// 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")
)
// 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)
// Callback when activation state changes.
onActivationStateChange func(*ActivationState)
// Optional canonical evaluator for B2 entitlement checks.
// When set and no JWT license is active, HasFeature/Status/SubscriptionState
// delegate to evaluator primitives (capabilities/limits/subscription state).
// When nil, falls through to existing tier-based logic.
evaluator *Evaluator
// Optional subscription state machine hook.
// Only tracks whether a hook is configured; current derivation remains claim/license based.
stateMachineConfigured bool
// Activation-key license server fields.
serverClient *LicenseServerClient // HTTP client for license server
activationState *ActivationState // Current activation state (nil if using legacy JWT)
grantRefresh *grantRefreshLoop // Background refresh loop (nil until started)
revocationPoll *revocationPollLoop // Background revocation feed poller (nil until started)
// Persistence reference for activation state save/load. Set via SetPersistence.
persistence *Persistence
}
// DefaultGracePeriod is the duration after license expiration during which
// features remain available. All grace period logic MUST use this constant.
const DefaultGracePeriod = 7 * 24 * time.Hour
// NewService creates a new license service.
func NewService() *Service {
return &Service{
gracePeriod: DefaultGracePeriod,
}
}
// ensureGracePeriodEnd sets the grace period end time on the license if not already set.
// Must be called while holding s.mu.
func (s *Service) ensureGracePeriodEnd() {
if s.license != nil && s.license.GracePeriodEnd == nil {
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
s.license.GracePeriodEnd = &gracePeriodEnd
}
}
// 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
}
// SetActivationStateChangeCallback sets a callback for activation-state changes.
func (s *Service) SetActivationStateChangeCallback(cb func(*ActivationState)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onActivationStateChange = cb
}
// SetEvaluator overrides the entitlement evaluator.
// In normal operation, Activate and Clear manage the evaluator automatically.
// This method exists for testing; production code should not call it directly.
func (s *Service) SetEvaluator(eval *Evaluator) {
s.mu.Lock()
defer s.mu.Unlock()
s.evaluator = eval
}
// SetStateMachine sets the optional subscription state machine hook.
// This is nil-safe and does not alter feature entitlement behavior.
func (s *Service) SetStateMachine(sm any) {
s.mu.Lock()
defer s.mu.Unlock()
s.stateMachineConfigured = sm != nil
}
// Evaluator returns the current evaluator, or nil if not set.
func (s *Service) Evaluator() *Evaluator {
s.mu.RLock()
defer s.mu.RUnlock()
return s.evaluator
}
// Activate validates and activates a license key.
// Activation keys (ppk_live_...) are routed to ActivateWithKey.
// Legacy JWT activation is only allowed in explicit dev mode for test fixtures.
func (s *Service) Activate(licenseKey string) (*License, error) {
licenseKey = strings.TrimSpace(licenseKey)
if strings.HasPrefix(licenseKey, ActivationKeyPrefix) {
return s.ActivateWithKey(licenseKey)
}
if !isLicenseValidationDevMode() {
if !looksLikeLegacyJWTLicense(licenseKey) {
return nil, fmt.Errorf("license key is not a supported v6 activation key or migratable v5 license")
}
return s.ActivateLegacyLicense(licenseKey)
}
license, err := ValidateLicense(licenseKey)
if err != nil {
return nil, fmt.Errorf("validate license: %w", err)
}
// JWT validated successfully — now safe to tear down any existing activation.
s.StopGrantRefresh()
s.StopRevocationPoll()
s.mu.Lock()
s.license = cloneLicense(license)
source := NewTokenSource(&s.license.Claims)
s.evaluator = NewEvaluator(source)
// Clear any activation state — this is now a legacy JWT license.
s.activationState = nil
cb := s.onLicenseChange
activationCB := s.onActivationStateChange
snapshot := cloneLicense(s.license)
persistence := s.persistence
s.mu.Unlock()
// Remove persisted activation state if present.
if persistence != nil {
_ = persistence.ClearActivationState()
}
if cb != nil {
cb(snapshot)
}
if activationCB != nil {
activationCB(nil)
}
// Keep legacy mutability in explicit dev-mode to avoid breaking existing
// test fixtures that patch claims after activation. In production mode,
// callers receive an immutable snapshot to prevent state tampering.
if isLicenseValidationDevMode() {
return s.license, nil
}
return snapshot, nil
}
func looksLikeLegacyJWTLicense(licenseKey string) bool {
parts := strings.Split(strings.TrimSpace(licenseKey), ".")
return len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != ""
}
// IsLicenseValidationDevMode reports whether legacy JWT validation is enabled
// for development and test fixtures.
func IsLicenseValidationDevMode() bool {
return isLicenseValidationDevMode()
}
// ActivateWithKey activates a license using an activation key from the license server.
// It creates an installation, receives a relay grant, parses it, and sets the
// resulting license as active. The activation state is persisted for background refresh.
func (s *Service) ActivateWithKey(activationKey string) (*License, error) {
s.mu.RLock()
client := s.serverClient
persistence := s.persistence
s.mu.RUnlock()
if client == nil {
return nil, fmt.Errorf("activation unavailable: license server client not configured")
}
// Generate a stable fingerprint for this installation.
fingerprint, err := generateFingerprint()
if err != nil {
return nil, fmt.Errorf("generate instance fingerprint: %w", err)
}
hostname, _ := os.Hostname()
req := ActivateInstallationRequest{
ActivationKey: activationKey,
InstanceFingerprint: fingerprint,
InstanceName: hostname,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
resp, err := client.Activate(ctx, req)
if err != nil {
return nil, fmt.Errorf("activation failed: %w", err)
}
return s.applyActivationResponse(resp, fingerprint, client.BaseURL(), persistence, ActivationContinuity{})
}
// ActivateLegacyLicense exchanges a legacy v5 JWT-style license for a v6 activation.
func (s *Service) ActivateLegacyLicense(legacyLicenseKey string) (*License, error) {
s.mu.RLock()
client := s.serverClient
persistence := s.persistence
s.mu.RUnlock()
if client == nil {
return nil, fmt.Errorf("activation unavailable: license server client not configured")
}
fingerprint, err := generateFingerprint()
if err != nil {
return nil, fmt.Errorf("generate instance fingerprint: %w", err)
}
hostname, _ := os.Hostname()
req := ExchangeLegacyLicenseRequest{
LegacyLicenseKey: legacyLicenseKey,
InstanceFingerprint: fingerprint,
InstanceName: hostname,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
resp, err := client.ExchangeLegacyLicense(ctx, req)
if err != nil {
return nil, fmt.Errorf("activation failed: %w", err)
}
return s.applyActivationResponse(resp, fingerprint, client.BaseURL(), persistence, ActivationContinuity{
LegacyMigration: true,
})
}
func (s *Service) applyActivationResponse(
resp *ActivateInstallationResponse,
fingerprint string,
serverURL string,
persistence *Persistence,
continuity ActivationContinuity,
) (*License, error) {
// Parse the grant JWT to extract claims.
gc, err := verifyAndParseGrantJWT(resp.Grant.JWT)
if err != nil {
return nil, fmt.Errorf("parse grant from activation: %w", err)
}
// Build the license from grant claims.
continuity = normalizeActivationContinuity(continuity)
lic := grantClaimsToLicenseWithContinuity(gc, resp.Grant.JWT, continuity)
// Build activation state for persistence and refresh.
now := time.Now().Unix()
state := &ActivationState{
InstallationID: resp.Installation.InstallationID,
InstallationToken: resp.Installation.InstallationToken,
LicenseID: resp.License.LicenseID,
GrantJWT: resp.Grant.JWT,
GrantJTI: resp.Grant.JTI,
GrantExpiresAt: resp.Grant.ParseExpiresAt(),
InstanceFingerprint: fingerprint,
LicenseServerURL: serverURL,
ActivatedAt: now,
LastRefreshedAt: now,
Continuity: continuity,
}
s.mu.Lock()
s.license = cloneLicense(lic)
source := NewTokenSource(&s.license.Claims)
s.evaluator = NewEvaluator(source)
s.activationState = state
cb := s.onLicenseChange
activationCB := s.onActivationStateChange
snapshot := cloneLicense(s.license)
stateSnapshot := cloneActivationState(state)
s.mu.Unlock()
// Apply refresh hints from the server.
s.SetRefreshHints(resp.RefreshPolicy)
// Persist activation state.
if persistence != nil {
if err := persistence.SaveActivationState(state); err != nil {
// Log but don't fail — the activation succeeded.
// The grant won't survive restarts without persistence, but that's a degraded mode.
fmt.Fprintf(os.Stderr, "warning: failed to persist activation state: %v\n", err)
}
}
if cb != nil {
cb(snapshot)
}
if activationCB != nil {
activationCB(stateSnapshot)
}
return snapshot, nil
}
// IsActivated returns true if the service has an active activation-key license.
func (s *Service) IsActivated() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.activationState != nil
}
// GetActivationState returns a copy of the current activation state, or nil.
func (s *Service) GetActivationState() *ActivationState {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneActivationState(s.activationState)
}
// SetLicenseServerClient sets the HTTP client for license server communication.
func (s *Service) SetLicenseServerClient(client *LicenseServerClient) {
s.mu.Lock()
defer s.mu.Unlock()
s.serverClient = client
}
// SetPersistence sets the persistence reference for activation state save/load.
func (s *Service) SetPersistence(p *Persistence) {
s.mu.Lock()
defer s.mu.Unlock()
s.persistence = p
}
// RestoreActivation restores the license from persisted activation state.
// Called during startup to resume an activation without re-activating.
// Even if the grant is expired, the refresh loop will renew it.
func (s *Service) RestoreActivation(state *ActivationState) error {
if state == nil {
return nil
}
gc, err := verifyAndParseGrantJWT(state.GrantJWT)
if err != nil {
return fmt.Errorf("parse persisted grant: %w", err)
}
stateCopy := *state
stateCopy.Continuity = normalizeActivationContinuity(stateCopy.Continuity)
lic := grantClaimsToLicenseWithContinuity(gc, stateCopy.GrantJWT, stateCopy.Continuity)
s.mu.Lock()
s.license = cloneLicense(lic)
source := NewTokenSource(&s.license.Claims)
s.evaluator = NewEvaluator(source)
s.activationState = &stateCopy
cb := s.onLicenseChange
activationCB := s.onActivationStateChange
snapshot := cloneLicense(s.license)
stateSnapshot := cloneActivationState(&stateCopy)
s.mu.Unlock()
if cb != nil {
cb(snapshot)
}
if activationCB != nil {
activationCB(stateSnapshot)
}
return nil
}
// CaptureLegacyMonitoredSystemGrandfatherFloor resolves the one-time
// monitored-system floor for a migrated legacy activation using the canonical
// deduped monitored-system count observed at runtime.
func (s *Service) CaptureLegacyMonitoredSystemGrandfatherFloor(count int) error {
if count < 0 {
count = 0
}
s.mu.Lock()
if s.activationState == nil {
s.mu.Unlock()
return nil
}
continuity := normalizeActivationContinuity(s.activationState.Continuity)
if !continuity.needsLegacyMonitoredSystemCapture() {
s.mu.Unlock()
return nil
}
currentLimit := 0
if s.license != nil {
currentLimit = monitoredSystemLimitFromClaims(s.license.Claims)
}
continuity.GrandfatheredMonitoredSystemsCapturedAt = time.Now().Unix()
if count > currentLimit {
continuity.GrandfatheredMaxMonitoredSystems = count
}
s.activationState.Continuity = continuity
shouldNotify := false
if s.license != nil {
claims := cloneClaims(s.license.Claims)
applyActivationContinuityToClaims(&claims, continuity)
updatedLimit := monitoredSystemLimitFromClaims(claims)
shouldNotify = updatedLimit != currentLimit
s.license.Claims = claims
source := NewTokenSource(&s.license.Claims)
s.evaluator = NewEvaluator(source)
}
stateCopy := *s.activationState
persistence := s.persistence
cb := s.onLicenseChange
activationCB := s.onActivationStateChange
snapshot := cloneLicense(s.license)
stateSnapshot := cloneActivationState(s.activationState)
s.mu.Unlock()
if persistence != nil {
if err := persistence.SaveActivationState(&stateCopy); err != nil {
return fmt.Errorf("persist activation continuity: %w", err)
}
}
if shouldNotify && cb != nil {
cb(snapshot)
}
if activationCB != nil {
activationCB(stateSnapshot)
}
return nil
}
func (s *Service) needsLegacyMonitoredSystemCaptureLocked() bool {
if s == nil || s.activationState == nil {
return false
}
return normalizeActivationContinuity(s.activationState.Continuity).needsLegacyMonitoredSystemCapture()
}
// NeedsLegacyMonitoredSystemCapture reports whether a migrated activation is
// still waiting for its one-time monitored-system continuity capture.
func (s *Service) NeedsLegacyMonitoredSystemCapture() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.needsLegacyMonitoredSystemCaptureLocked()
}
func (s *Service) monitoredSystemContinuityStatusLocked() *MonitoredSystemContinuityStatus {
if s == nil || s.activationState == nil {
return nil
}
continuity := normalizeActivationContinuity(s.activationState.Continuity)
if !continuity.LegacyMigration {
return nil
}
planLimit := 0
if gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT); err == nil && gc != nil {
planLimit = gc.MaxMonitoredSystems
}
effectiveLimit := planLimit
if s.license != nil {
effectiveLimit = monitoredSystemLimitFromClaims(s.license.Claims)
}
if effectiveLimit <= 0 {
effectiveLimit = planLimit
}
status := &MonitoredSystemContinuityStatus{
PlanLimit: planLimit,
EffectiveLimit: effectiveLimit,
CapturePending: continuity.needsLegacyMonitoredSystemCapture(),
CapturedAt: continuity.GrandfatheredMonitoredSystemsCapturedAt,
}
if continuity.GrandfatheredMaxMonitoredSystems > 0 {
status.GrandfatheredFloor = continuity.GrandfatheredMaxMonitoredSystems
}
return status
}
// Clear removes the current license.
// If an activation-key license is present, it also stops the refresh loop and clears the state.
func (s *Service) Clear() {
// Stop background loops first (outside the lock to avoid deadlock).
s.StopGrantRefresh()
s.StopRevocationPoll()
s.mu.Lock()
s.license = nil
s.evaluator = nil
persistence := s.persistence
hadActivation := s.activationState != nil
s.activationState = nil
cb := s.onLicenseChange
activationCB := s.onActivationStateChange
s.mu.Unlock()
// Clear persisted activation state if it existed.
if hadActivation && persistence != nil {
if err := persistence.ClearActivationState(); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to clear activation state: %v\n", err)
}
}
if cb != nil {
cb(nil)
}
if activationCB != nil {
activationCB(nil)
}
}
// Current returns the current license, or nil if none.
func (s *Service) Current() *License {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneLicense(s.license)
}
// SetCurrentForTesting injects a license pointer directly into service state.
// This is intended for deterministic unit tests.
func (s *Service) SetCurrentForTesting(license *License) {
s.mu.Lock()
defer s.mu.Unlock()
s.license = license
}
// CurrentUnsafeForTesting returns the internal license pointer for in-place mutation.
// This is intended for deterministic unit tests.
func (s *Service) CurrentUnsafeForTesting() *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.Lock()
defer s.mu.Unlock()
if s.license == nil {
return false
}
state := s.currentJWTSubscriptionStateLocked(time.Now())
return subscriptionStateHasPaidFeatures(state)
}
// HasFeature checks if the current license grants a feature.
func (s *Service) HasFeature(feature string) bool {
// In demo mode or dev mode, grant all Pro features
if isDemoMode() || isDevMode() {
return devModeFeatureEnabled(feature)
}
s.mu.Lock() // Need write lock since we may update grace period
defer s.mu.Unlock()
if s.license == nil {
// Hosted path: evaluator drives entitlements when no JWT is present.
if s.evaluator != nil {
// Always include free-tier features, regardless of evaluator state.
if TierHasFeature(TierFree, feature) {
return true
}
// Only grant paid capabilities when the subscription state permits it.
if !subscriptionStateHasPaidFeatures(s.evaluator.SubscriptionState()) {
return false
}
return s.evaluator.HasCapability(feature)
}
// No license activated — still grant free tier features
return TierHasFeature(TierFree, feature)
}
// JWT takes precedence whenever a license is present (including hybrid mode).
if !subscriptionStateHasPaidFeatures(s.currentJWTSubscriptionStateLocked(time.Now())) {
return TierHasFeature(TierFree, feature)
}
return s.license.HasFeature(feature)
}
// isDemoMode, isDevMode, and isLicenseValidationDevMode are defined in
// dev_mode_dev.go (default builds) and dev_mode_release.go (release builds).
// In release builds these always return false, preventing env-var bypass of
// feature gating and license signature validation.
const (
maxLicenseKeyLength = 16 << 10 // 16 KiB
maxLicenseSegmentLength = 8 << 10 // 8 KiB
maxLicensePayloadSize = 8 << 10 // 8 KiB decoded JSON
)
// 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 {
if s.evaluator != nil {
switch s.evaluator.SubscriptionState() {
case SubStateActive, SubStateTrial:
return LicenseStateActive, nil
case SubStateGrace:
return LicenseStateGracePeriod, nil
default:
// Suspended/canceled/expired (or unknown) are treated as non-entitled.
return LicenseStateExpired, nil
}
}
return LicenseStateNone, nil
}
state := s.currentJWTSubscriptionStateLocked(time.Now())
switch state {
case SubStateActive, SubStateTrial:
return LicenseStateActive, cloneLicense(s.license)
case SubStateGrace:
return LicenseStateGracePeriod, cloneLicense(s.license)
default:
return LicenseStateExpired, cloneLicense(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 %s or above", ErrFeatureNotIncluded, GetFeatureDisplayName(feature), GetFeatureMinTierName(feature))
}
return nil
}
// SubscriptionState returns the current normalized subscription state.
func (s *Service) SubscriptionState() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.stateMachineConfigured && s.license != nil && s.license.Claims.SubState != "" {
return string(s.license.Claims.SubState)
}
if s.license == nil && s.evaluator != nil {
return string(s.evaluator.SubscriptionState())
}
if s.license == nil {
return string(SubStateExpired)
}
return string(s.currentJWTSubscriptionStateLocked(time.Now()))
}
// 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,
Features: append([]string(nil), TierFeatures[TierFree]...),
}
if s.license == nil {
// Hosted path: evaluator drives status when no JWT is present.
if s.evaluator != nil {
status.PlanVersion = s.evaluator.PlanVersion()
subState := s.evaluator.SubscriptionState()
switch subState {
case SubStateActive, SubStateTrial, SubStateGrace:
status.Tier = TierPro // hosted billing-backed tenants are effectively "pro" while entitled
status.Valid = true
if subState == SubStateGrace {
status.InGracePeriod = true
}
if subState == SubStateTrial {
if trialEndsAt := s.evaluator.TrialEndsAt(); trialEndsAt != nil {
expiresAt := time.Unix(*trialEndsAt, 0).Format(time.RFC3339)
status.ExpiresAt = &expiresAt
status.DaysRemaining = remainingDaysCeil(*trialEndsAt, time.Now().Unix())
}
}
status.Features = unionFeatures(TierFeatures[TierFree], evaluatorFeatures(s.evaluator))
if maxSystems, ok := s.evaluator.GetLimit(MaxMonitoredSystemsLicenseGateKey); ok {
status.MaxMonitoredSystems = safeIntFromInt64(maxSystems)
}
if maxGuests, ok := s.evaluator.GetLimit("max_guests"); ok {
status.MaxGuests = safeIntFromInt64(maxGuests)
}
default:
status.Tier = TierFree
status.Valid = false
// Keep effective capabilities free-tier only when subscription is not entitled.
status.Features = append([]string(nil), TierFeatures[TierFree]...)
if defaultSystems := TierMonitoredSystemLimits[TierFree]; defaultSystems > 0 {
status.MaxMonitoredSystems = defaultSystems
}
status.MaxGuests = 0
}
} else {
// No license, no evaluator — apply the free-tier monitored-system limit.
if defaultSystems := TierMonitoredSystemLimits[TierFree]; defaultSystems > 0 {
status.MaxMonitoredSystems = defaultSystems
}
}
if isDemoMode() || isDevMode() {
status.Features = devModeFeatures()
}
status.MonitoredSystemContinuity = s.monitoredSystemContinuityStatusLocked()
return status
}
status.Email = s.license.Claims.Email
status.Tier = s.license.Claims.Tier
status.PlanVersion = s.license.Claims.EntitlementPlanVersion()
status.IsLifetime = s.license.IsLifetime()
status.DaysRemaining = s.license.DaysRemaining()
status.Features = s.license.AllFeatures()
if maxSystems, ok := s.license.Claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok {
status.MaxMonitoredSystems = safeIntFromInt64(maxSystems)
}
if maxGuests, ok := s.license.Claims.EffectiveLimits()["max_guests"]; ok {
status.MaxGuests = safeIntFromInt64(maxGuests)
}
status.MonitoredSystemContinuity = s.monitoredSystemContinuityStatusLocked()
// Apply the tier default monitored-system limit when claims don't specify one.
// For recognized tiers, use their defined limit (0 = unlimited for Cloud/MSP/Enterprise).
// For unrecognized tiers, fall back to free tier limit to prevent unlimited access.
if status.MaxMonitoredSystems == 0 {
if defaultSystems, ok := TierMonitoredSystemLimits[status.Tier]; ok {
status.MaxMonitoredSystems = defaultSystems
} else {
status.MaxMonitoredSystems = TierMonitoredSystemLimits[TierFree]
}
}
if s.license.ExpiresAt() != nil {
exp := s.license.ExpiresAt().Format(time.RFC3339)
status.ExpiresAt = &exp
}
switch subState := s.currentJWTSubscriptionStateLocked(time.Now()); subState {
case SubStateActive, SubStateTrial:
status.Valid = true
case SubStateGrace:
status.Valid = true
status.InGracePeriod = true
if s.license.GracePeriodEnd != nil {
graceEnd := s.license.GracePeriodEnd.Format(time.RFC3339)
status.GracePeriodEnd = &graceEnd
}
default:
status.Valid = false
status.Features = append([]string(nil), TierFeatures[TierFree]...)
// Downgrade limits to the free tier when subscription is not entitled.
if defaultSystems := TierMonitoredSystemLimits[TierFree]; defaultSystems > 0 {
status.MaxMonitoredSystems = defaultSystems
} else {
status.MaxMonitoredSystems = 0
}
status.MaxGuests = 0
}
if isDemoMode() || isDevMode() {
status.Features = devModeFeatures()
}
return status
}
func (s *Service) currentJWTSubscriptionStateLocked(now time.Time) SubscriptionState {
if s.license == nil {
return SubStateExpired
}
// Explicit revocation states in JWT claims should always revoke paid access.
switch s.license.Claims.SubState {
case SubStateSuspended, SubStateCanceled, SubStateExpired:
return s.license.Claims.SubState
}
// Expiration/grace handling applies regardless of claim substate.
if s.license.IsExpired() {
s.ensureGracePeriodEnd()
if s.license.GracePeriodEnd != nil && now.Before(*s.license.GracePeriodEnd) {
return SubStateGrace
}
return SubStateExpired
}
if s.license.Claims.SubState != "" {
return s.license.Claims.SubState
}
return SubStateActive
}
func subscriptionStateHasPaidFeatures(state SubscriptionState) bool {
switch state {
case SubStateActive, SubStateTrial, SubStateGrace:
return true
default:
return false
}
}
func evaluatorFeatures(eval *Evaluator) []string {
if eval == nil {
return []string{}
}
// Derive a stable, known capability list from the evaluator by enumerating
// all feature keys currently used by tier-based gating.
caps := make([]string, 0, len(allKnownFeatures()))
for _, feature := range allKnownFeatures() {
if eval.HasCapability(feature) {
caps = append(caps, feature)
}
}
sort.Strings(caps)
return caps
}
func allKnownFeatures() []string {
known := make(map[string]struct{}, 32)
for _, features := range TierFeatures {
for _, feature := range features {
known[feature] = struct{}{}
}
}
out := make([]string, 0, len(known))
for feature := range known {
out = append(out, feature)
}
sort.Strings(out)
return out
}
func unionFeatures(a, b []string) []string {
set := make(map[string]struct{}, len(a)+len(b))
for _, v := range a {
set[v] = struct{}{}
}
for _, v := range b {
set[v] = struct{}{}
}
out := make([]string, 0, len(set))
for v := range set {
out = append(out, v)
}
sort.Strings(out)
return out
}
func safeIntFromInt64(v int64) int {
// Avoid overflow on 32-bit platforms; clamp to max int.
maxInt := int64(^uint(0) >> 1)
if v > maxInt {
return int(maxInt)
}
if v < 0 {
return 0
}
return int(v)
}
func monitoredSystemLimitFromClaims(claims Claims) int {
if limit, ok := claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok {
return safeIntFromInt64(limit)
}
return 0
}
func remainingDaysCeil(expiresAtUnix, nowUnix int64) int {
deltaSeconds := expiresAtUnix - nowUnix
if deltaSeconds <= 0 {
return 0
}
return int(math.Ceil(float64(deltaSeconds) / 86400.0))
}
func cloneLicense(in *License) *License {
if in == nil {
return nil
}
out := *in
out.Claims = cloneClaims(in.Claims)
if in.GracePeriodEnd != nil {
graceEnd := *in.GracePeriodEnd
out.GracePeriodEnd = &graceEnd
}
return &out
}
func cloneActivationState(in *ActivationState) *ActivationState {
if in == nil {
return nil
}
out := *in
out.Continuity = normalizeActivationContinuity(in.Continuity)
return &out
}
func cloneClaims(in Claims) Claims {
out := in
if in.Features != nil {
out.Features = append([]string(nil), in.Features...)
}
if in.Capabilities != nil {
out.Capabilities = append([]string(nil), in.Capabilities...)
}
if in.Limits != nil {
out.Limits = make(map[string]int64, len(in.Limits))
for key, value := range in.Limits {
out.Limits[key] = value
}
}
if in.MetersEnabled != nil {
out.MetersEnabled = append([]string(nil), in.MetersEnabled...)
}
return out
}
// 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
}
if len(licenseKey) > maxLicenseKeyLength {
return nil, fmt.Errorf("%w: license key exceeds size limit", ErrMalformedLicense)
}
// Parse JWT (base64url.base64url.base64url)
parts := strings.Split(licenseKey, ".")
if len(parts) != 3 {
return nil, ErrMalformedLicense
}
for _, part := range parts {
if part == "" {
return nil, fmt.Errorf("%w: empty jwt segment", ErrMalformedLicense)
}
if len(part) > maxLicenseSegmentLength {
return nil, fmt.Errorf("%w: jwt segment exceeds size limit", 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)
}
if len(payloadBytes) > maxLicensePayloadSize {
return nil, fmt.Errorf("%w: payload exceeds size limit", 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 := isLicenseValidationDevMode()
signedData := []byte(parts[0] + "." + parts[1])
key := currentPublicKey()
if len(key) > 0 {
if !ed25519.Verify(key, 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)
gracePeriodEnd := expirationTime.Add(DefaultGracePeriod)
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
}
// generateFingerprint creates a random UUID v4 for identifying this installation.
func generateFingerprint() (string, error) {
var uuid [16]byte
if _, err := rand.Read(uuid[:]); err != nil {
return "", err
}
// Set version 4 bits.
uuid[6] = (uuid[6] & 0x0f) | 0x40
// Set variant bits.
uuid[8] = (uuid[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]), 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, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal test license claims: %w", err)
}
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
}
// GenerateGrantJWTForTesting creates a signed grant JWT and returns the matching
// public key so tests can install it via SetPublicKey before verification.
// DO NOT USE IN PRODUCTION.
func GenerateGrantJWTForTesting(claims GrantClaims) (string, ed25519.PublicKey, error) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", nil, fmt.Errorf("generate grant test key pair: %w", err)
}
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA"}`))
payloadBytes, err := json.Marshal(claims)
if err != nil {
return "", nil, fmt.Errorf("marshal grant test claims: %w", err)
}
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
signedData := []byte(header + "." + payload)
signature := ed25519.Sign(privateKey, signedData)
return header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(signature), publicKey, nil
}