Files
pulse/pkg/licensing/service.go
T
rcourtman 3badd0a840 fix(licensing): honor activated licenses for dev-grant-excluded features in dev mode
In dev/demo builds HasFeature returned the implicit dev grant verdict
unconditionally, so features excluded from that grant (white_label,
multi_user, unlimited, env-gated multi_tenant) were denied even when an
explicitly activated, signature-verified license carried them - while
/api/license/status reported them active from the same claims. That
inconsistency made branded report rendering impossible to exercise in
dev builds. Excluded features now fall through to real entitlement
evaluation, so an activated license behaves the same in dev as in
release builds; the no-license dev posture is unchanged.
2026-06-10 17:09:00 +01:00

1007 lines
29 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
// Canonical runtime version supplied by the owning process. This is used for
// authenticated installation/version attribution on activation and refresh.
clientVersion string
// Canonical runtime build supplied by the owning process. This is separate
// from version so support can distinguish public/community binaries from
// private Pulse Pro binaries even when they share a release tag.
runtimeIdentity RuntimeIdentity
}
// 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,
runtimeIdentity: CommunityRuntimeIdentity(),
}
}
// 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
clientVersion := s.clientVersion
runtimeIdentity := NormalizeRuntimeIdentity(s.runtimeIdentity)
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,
ClientVersion: clientVersion,
Runtime: CloneRuntimeIdentity(runtimeIdentity),
}
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
clientVersion := s.clientVersion
runtimeIdentity := NormalizeRuntimeIdentity(s.runtimeIdentity)
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,
ClientVersion: clientVersion,
Runtime: CloneRuntimeIdentity(runtimeIdentity),
}
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
}
// SetClientVersion records the canonical runtime version for authenticated
// installation/version attribution on activation and grant refresh.
func (s *Service) SetClientVersion(version string) {
s.mu.Lock()
defer s.mu.Unlock()
s.clientVersion = strings.TrimSpace(version)
}
// SetRuntimeIdentity records the canonical runtime build for authenticated
// installation attribution on activation and grant refresh.
func (s *Service) SetRuntimeIdentity(identity RuntimeIdentity) {
s.mu.Lock()
defer s.mu.Unlock()
s.runtimeIdentity = NormalizeRuntimeIdentity(identity)
}
// 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
}
// 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() {
if devModeFeatureEnabled(feature) {
return true
}
// Features excluded from the implicit dev grant (white_label,
// multi_user, unlimited, env-gated multi_tenant) fall through to
// real entitlement evaluation, so an explicitly activated license
// behaves the same in dev builds as in release builds.
}
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 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]...)
status.MaxGuests = 0
}
} else {
// No license, no evaluator: Community remains active for core monitoring.
}
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 maxGuests, ok := s.license.Claims.EffectiveLimits()["max_guests"]; ok {
status.MaxGuests = safeIntFromInt64(maxGuests)
}
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]...)
status.MaxGuests = 0
}
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 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
}