mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
1177 lines
35 KiB
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
|
|
}
|