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) // 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 } // 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 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) } // 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) } // 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) } func (s *Service) applyActivationResponse( resp *ActivateInstallationResponse, fingerprint string, serverURL string, persistence *Persistence, ) (*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. lic := grantClaimsToLicense(gc, resp.Grant.JWT) // 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, } s.mu.Lock() s.license = cloneLicense(lic) source := NewTokenSource(&s.license.Claims) s.evaluator = NewEvaluator(source) s.activationState = state cb := s.onLicenseChange snapshot := cloneLicense(s.license) 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) } 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() if s.activationState == nil { return nil } stateCopy := *s.activationState return &stateCopy } // 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) } lic := grantClaimsToLicense(gc, state.GrantJWT) s.mu.Lock() s.license = cloneLicense(lic) source := NewTokenSource(&s.license.Claims) s.evaluator = NewEvaluator(source) s.activationState = state cb := s.onLicenseChange snapshot := cloneLicense(s.license) s.mu.Unlock() if cb != nil { cb(snapshot) } 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 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) } } // 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 true } 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 } } 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) } // 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 } 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. known := make(map[string]struct{}, 32) for _, features := range TierFeatures { for _, feature := range features { known[feature] = struct{}{} } } caps := make([]string, 0, len(known)) for feature := range known { if eval.HasCapability(feature) { caps = append(caps, feature) } } sort.Strings(caps) return caps } 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 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 }