645 lines
18 KiB
Go
645 lines
18 KiB
Go
|
|
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
|
||
|
|
}
|