Tonnage-app-IMCO/licence.go

646 lines
18 KiB
Go
Raw Permalink Normal View History

2026-04-23 06:24:24 +00:00
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"`
}
2026-04-23 10:09:13 +00:00
const embeddedLicensePublicKeyBase64 = "k0k+ZtOpDWTyO8+uJY9+yL2S/ZzOxyBbaUldw1SJDGc="
var embeddedLicensePolicy = LicenseConfig{
Enabled: true,
TrialDays: 7,
RequireAfterTrial: true,
DataDir: "license",
PublicKeyBase64: embeddedLicensePublicKeyBase64,
ProductCode: "force_monitor",
}
func runtimeLicenseConfig() LicenseConfig {
return embeddedLicensePolicy
}
2026-04-23 06:24:24 +00:00
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 {
2026-04-23 10:09:13 +00:00
return errors.New("no license public key configured; set the embedded verifier public key")
2026-04-23 06:24:24 +00:00
}
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 {
2026-04-23 10:09:13 +00:00
m.active = nil
2026-04-23 06:24:24 +00:00
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)
}
2026-04-23 10:09:13 +00:00
// The private signing key should live only in a separate offline signer tool.
// This app intentionally does not include any signing helper.
2026-04-23 06:24:24 +00:00
// 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
}