// Package client provides license validation for client applications. // It contacts the license server, falls back to a local signed cache // during offline/grace periods, and blocks when the grace period expires. package client import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "os" "path/filepath" "runtime" "strings" "time" ) // ----------------------------------------------------------------------- // Configuration // ----------------------------------------------------------------------- // Config holds all settings for the license client. type Config struct { // ServerURL is the base URL of your license server. // e.g. "https://licenses.yourdomain.com" ServerURL string // LicenseKey is the customer's license key (XXXX-XXXX-XXXX-XXXX). LicenseKey string // AppName is used to determine the cache file location. AppName string // GracePeriod is how long the app may run without reaching the server. // Default: 7 days. GracePeriod time.Duration // RequestTimeout is the HTTP timeout when contacting the server. // Default: 5 seconds. Keep this short so startup isn't slow offline. RequestTimeout time.Duration // CacheSecret is used to HMAC-sign the local cache file so users // cannot tamper with it. Should be a constant baked into your binary. // Must be at least 16 bytes. CacheSecret []byte } func (c *Config) defaults() { if c.GracePeriod == 0 { c.GracePeriod = 7 * 24 * time.Hour } if c.RequestTimeout == 0 { c.RequestTimeout = 5 * time.Second } if c.AppName == "" { c.AppName = "app" } } // ----------------------------------------------------------------------- // Result // ----------------------------------------------------------------------- // Result is returned by Check. type Result struct { Valid bool Offline bool // true if validated from local cache CachedAt time.Time // when the cache was last refreshed from server GraceLeft time.Duration // how much grace period remains (offline only) Message string } func (r Result) String() string { if !r.Valid { return fmt.Sprintf("invalid: %s", r.Message) } if r.Offline { return fmt.Sprintf("valid (offline, grace period: %s remaining)", r.GraceLeft.Round(time.Hour)) } return "valid (server verified)" } // ----------------------------------------------------------------------- // Cache // ----------------------------------------------------------------------- type cache struct { LicenseKey string `json:"license_key"` MachineID string `json:"machine_id"` LastValidated time.Time `json:"last_validated"` Signature string `json:"sig"` } // sign returns HMAC-SHA256 of the cache payload fields (excluding sig itself). func (c *cache) sign(secret []byte) string { payload := fmt.Sprintf("%s|%s|%s", c.LicenseKey, c.MachineID, c.LastValidated.UTC().Format(time.RFC3339)) mac := hmac.New(sha256.New, secret) mac.Write([]byte(payload)) return hex.EncodeToString(mac.Sum(nil)) } func (c *cache) verify(secret []byte) bool { expected := c.sign(secret) return hmac.Equal([]byte(expected), []byte(c.Signature)) } // ----------------------------------------------------------------------- // Client // ----------------------------------------------------------------------- // Client validates licenses against a remote server with offline grace period. type Client struct { cfg Config machineID string cachePath string http *http.Client } // New creates a license Client. Call Check() to validate. func New(cfg Config, machineID string) (*Client, error) { cfg.defaults() if cfg.ServerURL == "" { return nil, errors.New("license: ServerURL is required") } if cfg.LicenseKey == "" { return nil, errors.New("license: LicenseKey is required") } if len(cfg.CacheSecret) < 16 { return nil, errors.New("license: CacheSecret must be at least 16 bytes") } cachePath, err := resolveCachePath(cfg.AppName) if err != nil { return nil, fmt.Errorf("license: cannot resolve cache path: %w", err) } return &Client{ cfg: cfg, machineID: machineID, cachePath: cachePath, http: &http.Client{ Timeout: cfg.RequestTimeout, }, }, nil } // Check validates the license. Order of operations: // 1. Try the license server (fast timeout so startup stays snappy) // 2. If server unreachable → load local cache, check grace period // 3. If server reachable but license invalid → block immediately (no grace) func (c *Client) Check() Result { // --- Attempt online validation --- onlineErr := c.validateOnline() if onlineErr == nil { // Success — refresh cache and return valid c.saveCache() return Result{Valid: true} } // --- Server was reachable but rejected the license --- if !isNetworkError(onlineErr) { return Result{Valid: false, Message: onlineErr.Error()} } // --- Network error — try grace period --- cached, cacheErr := c.loadCache() if cacheErr != nil { return Result{ Valid: false, Message: fmt.Sprintf("license server unreachable and no valid cache found (%v)", cacheErr), } } // Verify cache belongs to this machine and key if cached.LicenseKey != c.cfg.LicenseKey { return Result{Valid: false, Message: "license server unreachable and cached key does not match"} } if cached.MachineID != c.machineID { return Result{Valid: false, Message: "license server unreachable and cached machine ID does not match"} } elapsed := time.Since(cached.LastValidated) graceLeft := c.cfg.GracePeriod - elapsed if graceLeft <= 0 { return Result{ Valid: false, Message: fmt.Sprintf("license server unreachable and grace period expired (%s ago)", (-graceLeft).Round(time.Hour)), } } return Result{ Valid: true, Offline: true, CachedAt: cached.LastValidated, GraceLeft: graceLeft, Message: fmt.Sprintf("offline — %s grace period remaining", graceLeft.Round(time.Hour)), } } // CachePath returns the path of the local cache file (useful for debugging). func (c *Client) CachePath() string { return c.cachePath } // ----------------------------------------------------------------------- // Online validation // ----------------------------------------------------------------------- type validateRequest struct { LicenseKey string `json:"license_key"` MachineID string `json:"machine_id"` Hostname string `json:"hostname"` OS string `json:"os"` } type validateResponse struct { Valid bool `json:"valid"` Message string `json:"message"` } func (c *Client) validateOnline() error { hostname, _ := os.Hostname() body, _ := json.Marshal(validateRequest{ LicenseKey: c.cfg.LicenseKey, MachineID: c.machineID, Hostname: hostname, OS: runtime.GOOS, }) resp, err := c.http.Post( strings.TrimRight(c.cfg.ServerURL, "/")+"/api/v1/validate", "application/json", bytes.NewReader(body), ) if err != nil { return err // network error } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("%s", resp.Status) } data, _ := io.ReadAll(resp.Body) var result validateResponse if err := json.Unmarshal(data, &result); err != nil { return fmt.Errorf("invalid server response: %w", err) } if !result.Valid { return fmt.Errorf("%s", result.Message) } return nil } // ----------------------------------------------------------------------- // Cache read / write // ----------------------------------------------------------------------- func (c *Client) saveCache() error { entry := &cache{ LicenseKey: c.cfg.LicenseKey, MachineID: c.machineID, LastValidated: time.Now().UTC(), } entry.Signature = entry.sign(c.cfg.CacheSecret) if err := os.MkdirAll(filepath.Dir(c.cachePath), 0700); err != nil { return err } data, _ := json.Marshal(entry) return os.WriteFile(c.cachePath, data, 0600) } func (c *Client) loadCache() (*cache, error) { data, err := os.ReadFile(c.cachePath) if err != nil { return nil, fmt.Errorf("no cache file: %w", err) } var entry cache if err := json.Unmarshal(data, &entry); err != nil { return nil, fmt.Errorf("corrupt cache: %w", err) } if !entry.verify(c.cfg.CacheSecret) { // Cache was tampered with — delete it os.Remove(c.cachePath) return nil, errors.New("cache signature invalid (possible tampering)") } return &entry, nil } // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- // isNetworkError returns true for connectivity failures (server unreachable, // DNS failure, timeout) as opposed to server-side rejections. func isNetworkError(err error) bool { if err == nil { return false } var netErr net.Error if errors.As(err, &netErr) { return true } msg := err.Error() return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host") || strings.Contains(msg, "network is unreachable") || strings.Contains(msg, "i/o timeout") || strings.Contains(msg, "EOF") } // resolveCachePath returns the platform-appropriate cache file path: // - Windows: %APPDATA%\\license.cache // - macOS: ~/Library/Application Support//license.cache // - Linux: ~/.config//license.cache func resolveCachePath(appName string) (string, error) { var base string switch runtime.GOOS { case "windows": base = os.Getenv("APPDATA") if base == "" { return "", errors.New("APPDATA not set") } case "darwin": home, err := os.UserHomeDir() if err != nil { return "", err } base = filepath.Join(home, "Library", "Application Support") default: // linux and others if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { base = xdg } else { home, err := os.UserHomeDir() if err != nil { return "", err } base = filepath.Join(home, ".config") } } return filepath.Join(base, appName, "license.cache"), nil }