From 49860df5a0fff2cdbe02984aaf10dc4403c345b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Ro=C5=BEi=C4=8D?= Date: Thu, 23 Apr 2026 08:24:24 +0200 Subject: [PATCH] added licence.go --- licence.go | 644 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 644 insertions(+) create mode 100644 licence.go diff --git a/licence.go b/licence.go new file mode 100644 index 0000000..91ca728 --- /dev/null +++ b/licence.go @@ -0,0 +1,644 @@ +package main + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +// --------------------------------------------------------------------------- +// License config and public status types +// --------------------------------------------------------------------------- + +type LicenseConfig struct { + Enabled bool `yaml:"enabled"` + TrialDays int `yaml:"trial_days"` + RequireAfterTrial bool `yaml:"require_after_trial"` + DataDir string `yaml:"data_dir"` + PublicKeyBase64 string `yaml:"public_key_base64"` + ProductCode string `yaml:"product_code"` +} + +type ActivationRequest struct { + App string `json:"app"` + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Fingerprint string `json:"fingerprint"` + FingerprintShort string `json:"fingerprint_short"` + Components []string `json:"components"` +} + +type LicenseStatus struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + Locked bool `json:"locked"` + Message string `json:"message"` + Fingerprint string `json:"fingerprint"` + FingerprintShort string `json:"fingerprint_short"` + Hostname string `json:"hostname"` + TrialDays int `json:"trial_days"` + DaysRemaining int `json:"days_remaining"` + TrialStartedAt string `json:"trial_started_at,omitempty"` + TrialExpiresAt string `json:"trial_expires_at,omitempty"` + Customer string `json:"customer,omitempty"` + LicenseID string `json:"license_id,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Features []string `json:"features,omitempty"` + ActivationConfigured bool `json:"activation_configured"` + Tampered bool `json:"tampered"` +} + +type TrialState struct { + FirstRunUTC string `json:"first_run_utc"` + LastSeenUTC string `json:"last_seen_utc"` + Fingerprint string `json:"fingerprint"` + Checksum string `json:"checksum"` + Tampered bool `json:"tampered"` +} + +type SignedLicense struct { + App string `json:"app"` + LicenseID string `json:"license_id"` + Customer string `json:"customer"` + Fingerprint string `json:"fingerprint"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at,omitempty"` + Features []string `json:"features,omitempty"` + Signature string `json:"signature"` +} + +type licensePayload struct { + App string `json:"app"` + LicenseID string `json:"license_id"` + Customer string `json:"customer"` + Fingerprint string `json:"fingerprint"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at,omitempty"` + Features []string `json:"features,omitempty"` +} + +type LicenseManager struct { + mu sync.RWMutex + cfg LicenseConfig + dataDir string + trialPath string + licensePath string + publicKey ed25519.PublicKey + fingerprint string + components []string + hostname string + trial TrialState + active *SignedLicense + lastStatus LicenseStatus +} + +const trialSalt = "force-monitor-trial-v1" + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +func NewLicenseManager(cfg LicenseConfig, dataDir string) (*LicenseManager, error) { + if !cfg.Enabled { + return &LicenseManager{cfg: cfg, dataDir: dataDir}, nil + } + if cfg.TrialDays <= 0 { + cfg.TrialDays = 7 + } + if strings.TrimSpace(cfg.ProductCode) == "" { + cfg.ProductCode = "force_monitor" + } + if strings.TrimSpace(dataDir) == "" { + dataDir = "license" + } + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("create license data dir: %w", err) + } + + fp, comps, err := buildMachineFingerprint(cfg.ProductCode) + if err != nil { + return nil, err + } + hostname, _ := os.Hostname() + + m := &LicenseManager{ + cfg: cfg, + dataDir: dataDir, + trialPath: filepath.Join(dataDir, "trial_state.json"), + licensePath: filepath.Join(dataDir, "license.json"), + fingerprint: fp, + components: comps, + hostname: hostname, + } + + if strings.TrimSpace(cfg.PublicKeyBase64) != "" { + pk, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.PublicKeyBase64)) + if err != nil { + return nil, fmt.Errorf("decode license public key: %w", err) + } + if len(pk) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid license public key size") + } + m.publicKey = ed25519.PublicKey(pk) + } + + if err := m.loadOrCreateTrial(); err != nil { + return nil, err + } + if err := m.loadExistingLicense(); err != nil { + return nil, err + } + m.refreshStatusLocked() + return m, nil +} + +// --------------------------------------------------------------------------- +// Public methods +// --------------------------------------------------------------------------- + +func (m *LicenseManager) BuildActivationRequest() ActivationRequest { + m.mu.RLock() + defer m.mu.RUnlock() + return ActivationRequest{ + App: m.cfg.ProductCode, + Version: version, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Hostname: m.hostname, + Platform: runtime.GOOS + "/" + runtime.GOARCH, + Fingerprint: m.fingerprint, + FingerprintShort: shortFingerprint(m.fingerprint), + Components: append([]string(nil), m.components...), + } +} + +func (m *LicenseManager) Status() LicenseStatus { + m.mu.Lock() + defer m.mu.Unlock() + m.refreshStatusLocked() + return m.lastStatus +} + +func (m *LicenseManager) Touch() error { + if m == nil || !m.cfg.Enabled { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now().UTC() + lastSeen, _ := time.Parse(time.RFC3339, m.trial.LastSeenUTC) + if !lastSeen.IsZero() && now.Add(2*time.Minute).Before(lastSeen) { + m.trial.Tampered = true + } + if !lastSeen.IsZero() && now.Sub(lastSeen) < 15*time.Minute && !m.trial.Tampered { + m.refreshStatusLocked() + return nil + } + m.trial.LastSeenUTC = now.Format(time.RFC3339) + m.trial.Fingerprint = m.fingerprint + m.trial.Checksum = m.signTrialState(m.trial) + if err := writeJSONFileAtomic(m.trialPath, m.trial); err != nil { + return err + } + m.refreshStatusLocked() + return nil +} + +func (m *LicenseManager) ActivateFromText(text string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.cfg.Enabled { + return errors.New("licensing disabled") + } + if len(m.publicKey) != ed25519.PublicKeySize { + return errors.New("no license public key configured; set license.public_key_base64 first") + } + + text = strings.TrimSpace(text) + if text == "" { + return errors.New("license text is empty") + } + + var lic SignedLicense + if err := json.Unmarshal([]byte(text), &lic); err != nil { + return fmt.Errorf("parse license json: %w", err) + } + if err := m.validateLicenseLocked(lic); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(m.licensePath), 0o755); err != nil { + return fmt.Errorf("create license dir: %w", err) + } + if err := writeBytesAtomic(m.licensePath, []byte(text)); err != nil { + return fmt.Errorf("write license: %w", err) + } + m.active = &lic + m.refreshStatusLocked() + return nil +} + +// --------------------------------------------------------------------------- +// Internal load / validate +// --------------------------------------------------------------------------- + +func (m *LicenseManager) loadOrCreateTrial() error { + state, err := readJSONFile[TrialState](m.trialPath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read trial state: %w", err) + } + now := time.Now().UTC().Format(time.RFC3339) + state = TrialState{ + FirstRunUTC: now, + LastSeenUTC: now, + Fingerprint: m.fingerprint, + } + state.Checksum = m.signTrialState(state) + if err := writeJSONFileAtomic(m.trialPath, state); err != nil { + return fmt.Errorf("create trial state: %w", err) + } + } + + if state.Checksum != m.signTrialState(state) { + state.Tampered = true + } + if state.Fingerprint != "" && state.Fingerprint != m.fingerprint { + state.Tampered = true + } + m.trial = state + return nil +} + +func (m *LicenseManager) loadExistingLicense() error { + data, err := os.ReadFile(m.licensePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + m.active = nil + return nil + } + return fmt.Errorf("read existing license: %w", err) + } + var lic SignedLicense + if err := json.Unmarshal(data, &lic); err != nil { + return fmt.Errorf("parse existing license: %w", err) + } + if err := m.validateLicenseLocked(lic); err != nil { + return nil + } + m.active = &lic + return nil +} + +func (m *LicenseManager) validateLicenseLocked(lic SignedLicense) error { + if strings.TrimSpace(lic.App) == "" { + return errors.New("license app is empty") + } + if lic.App != m.cfg.ProductCode { + return fmt.Errorf("license app mismatch: got %q want %q", lic.App, m.cfg.ProductCode) + } + if strings.TrimSpace(lic.Fingerprint) == "" { + return errors.New("license fingerprint is empty") + } + if !strings.EqualFold(strings.TrimSpace(lic.Fingerprint), strings.TrimSpace(m.fingerprint)) { + return errors.New("license fingerprint does not match this machine") + } + if strings.TrimSpace(lic.LicenseID) == "" { + return errors.New("license_id is required") + } + if strings.TrimSpace(lic.IssuedAt) == "" { + return errors.New("issued_at is required") + } + if _, err := time.Parse(time.RFC3339, lic.IssuedAt); err != nil { + return fmt.Errorf("invalid issued_at: %w", err) + } + if strings.TrimSpace(lic.ExpiresAt) != "" { + exp, err := time.Parse(time.RFC3339, lic.ExpiresAt) + if err != nil { + return fmt.Errorf("invalid expires_at: %w", err) + } + if time.Now().UTC().After(exp) { + return errors.New("license has expired") + } + } + if len(m.publicKey) != ed25519.PublicKeySize { + return errors.New("no public key configured") + } + sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(lic.Signature)) + if err != nil { + return fmt.Errorf("decode license signature: %w", err) + } + payload := licensePayload{ + App: lic.App, + LicenseID: lic.LicenseID, + Customer: lic.Customer, + Fingerprint: lic.Fingerprint, + IssuedAt: lic.IssuedAt, + ExpiresAt: lic.ExpiresAt, + Features: lic.Features, + } + b, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal license payload: %w", err) + } + if !ed25519.Verify(m.publicKey, b, sig) { + return errors.New("invalid license signature") + } + return nil +} + +func (m *LicenseManager) refreshStatusLocked() { + status := LicenseStatus{ + Enabled: m.cfg.Enabled, + Fingerprint: m.fingerprint, + FingerprintShort: shortFingerprint(m.fingerprint), + Hostname: m.hostname, + TrialDays: m.cfg.TrialDays, + ActivationConfigured: len(m.publicKey) == ed25519.PublicKeySize, + Tampered: m.trial.Tampered, + } + + if !m.cfg.Enabled { + status.Mode = "disabled" + status.Message = "licensing disabled" + status.Locked = false + m.lastStatus = status + return + } + + if m.active != nil { + status.Mode = "licensed" + status.Locked = false + status.Message = "license active" + status.Customer = m.active.Customer + status.LicenseID = m.active.LicenseID + status.Features = append([]string(nil), m.active.Features...) + status.ExpiresAt = m.active.ExpiresAt + m.lastStatus = status + return + } + + start, _ := time.Parse(time.RFC3339, m.trial.FirstRunUTC) + status.TrialStartedAt = m.trial.FirstRunUTC + + if start.IsZero() || m.trial.Tampered { + status.Mode = "trial_invalid" + status.Locked = true + status.Message = "trial state invalid or tampered" + m.lastStatus = status + return + } + + exp := start.Add(time.Duration(m.cfg.TrialDays) * 24 * time.Hour) + status.TrialExpiresAt = exp.Format(time.RFC3339) + days := int(time.Until(exp).Hours() / 24) + if time.Until(exp) > 0 { + days++ + } + if days < 0 { + days = 0 + } + status.DaysRemaining = days + + if time.Now().UTC().Before(exp) { + status.Mode = "trial" + status.Locked = false + status.Message = fmt.Sprintf("trial active: %d day(s) remaining", status.DaysRemaining) + m.lastStatus = status + return + } + + if m.cfg.RequireAfterTrial { + status.Mode = "expired" + status.Locked = true + status.Message = "trial expired; activation required" + } else { + status.Mode = "grace" + status.Locked = false + status.Message = "trial expired, but app allowed to continue" + } + m.lastStatus = status +} + +// --------------------------------------------------------------------------- +// Fingerprint helpers +// --------------------------------------------------------------------------- + +func buildMachineFingerprint(productCode string) (string, []string, error) { + parts := []string{} + + add := func(label, value string) { + v := normalizeMachineValue(value) + if v != "" { + parts = append(parts, label+"="+v) + } + } + + if runtime.GOOS == "windows" { + add("uuid", runWindowsCommand(`(Get-CimInstance Win32_ComputerSystemProduct).UUID`)) + add("board", runWindowsCommand(`(Get-CimInstance Win32_BaseBoard).SerialNumber`)) + add("machineguid", runWindowsCommand(`(Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Cryptography").MachineGuid`)) + add("bios", runWindowsCommand(`(Get-CimInstance Win32_BIOS).SerialNumber`)) + } else { + add("machineid", readTextFile("/etc/machine-id")) + add("product_uuid", readTextFile("/sys/class/dmi/id/product_uuid")) + add("board_serial", readTextFile("/sys/class/dmi/id/board_serial")) + } + + add("hostname", hostNameSafe()) + add("mac", firstPhysicalMAC()) + + if len(parts) == 0 { + return "", nil, errors.New("could not derive machine fingerprint") + } + + raw := strings.Join(parts, "|") + "|app=" + normalizeMachineValue(productCode) + "|salt=force-monitor-fp-v1" + sum := sha256.Sum256([]byte(raw)) + return strings.ToUpper(hex.EncodeToString(sum[:])), parts, nil +} + +func normalizeMachineValue(s string) string { + s = strings.TrimSpace(strings.ToUpper(s)) + s = strings.ReplaceAll(s, "\x00", "") + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + s = strings.ReplaceAll(s, " ", "") + switch s { + case "", "TOBEFILLEDBYO.E.M.", "NONE", "DEFAULTSTRING", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", "00000000-0000-0000-0000-000000000000": + return "" + } + return s +} + +func runWindowsCommand(script string) string { + cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script) + out, err := cmd.Output() + if err == nil { + return string(out) + } + cmd = exec.Command("cmd.exe", "/C", "powershell", "-NoProfile", "-NonInteractive", "-Command", script) + out, err = cmd.Output() + if err == nil { + return string(out) + } + return "" +} + +func readTextFile(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(b) +} + +func hostNameSafe() string { + h, _ := os.Hostname() + return h +} + +func firstPhysicalMAC() string { + ifs, err := net.Interfaces() + if err != nil { + return "" + } + for _, ifc := range ifs { + if ifc.Flags&net.FlagLoopback != 0 { + continue + } + if ifc.Flags&net.FlagUp == 0 { + continue + } + if len(ifc.HardwareAddr) == 0 { + continue + } + return ifc.HardwareAddr.String() + } + return "" +} + +func shortFingerprint(full string) string { + full = strings.TrimSpace(full) + if len(full) <= 16 { + return full + } + return full[:8] + "-" + full[8:16] +} + +// --------------------------------------------------------------------------- +// Trial integrity helpers +// --------------------------------------------------------------------------- + +func (m *LicenseManager) signTrialState(state TrialState) string { + sum := sha256.Sum256([]byte( + strings.Join([]string{ + state.FirstRunUTC, + state.LastSeenUTC, + state.Fingerprint, + boolToString(state.Tampered), + trialSalt, + m.cfg.ProductCode, + }, "|"), + )) + return hex.EncodeToString(sum[:]) +} + +func boolToString(v bool) string { + if v { + return "1" + } + return "0" +} + +// --------------------------------------------------------------------------- +// File helpers +// --------------------------------------------------------------------------- + +func writeJSONFileAtomic(path string, v any) error { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return writeBytesAtomic(path, b) +} + +func writeBytesAtomic(path string, b []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func readJSONFile[T any](path string) (T, error) { + var zero T + b, err := os.ReadFile(path) + if err != nil { + return zero, err + } + var out T + if err := json.Unmarshal(b, &out); err != nil { + return zero, err + } + return out, nil +} + +// Optional helper for external tools: create canonical bytes to sign. +// Keep this available so you can reuse the same code in a separate +// private license generator app without changing the verification logic. +func MarshalLicensePayloadForSigning(lic SignedLicense) ([]byte, error) { + payload := licensePayload{ + App: lic.App, + LicenseID: lic.LicenseID, + Customer: lic.Customer, + Fingerprint: lic.Fingerprint, + IssuedAt: lic.IssuedAt, + ExpiresAt: lic.ExpiresAt, + Features: lic.Features, + } + return json.Marshal(payload) +} + +// Helper for a future private signing tool. +func SignLicenseWithPrivateKey(lic SignedLicense, privateKeyBase64 string) (SignedLicense, error) { + privRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKeyBase64)) + if err != nil { + return lic, fmt.Errorf("decode private key: %w", err) + } + if len(privRaw) != ed25519.PrivateKeySize { + return lic, fmt.Errorf("invalid private key size") + } + payloadBytes, err := MarshalLicensePayloadForSigning(lic) + if err != nil { + return lic, err + } + sig := ed25519.Sign(ed25519.PrivateKey(privRaw), payloadBytes) + lic.Signature = base64.StdEncoding.EncodeToString(sig) + return lic, nil +} + +// Small utility for loading a signed license from a reader if you later want +// to support multipart file upload without changing the validation flow. +func ReadLicenseText(r io.Reader) (string, error) { + b, err := io.ReadAll(r) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +}