initital commit

This commit is contained in:
2026-05-05 08:44:55 +00:00
commit a1922f5be1
22 changed files with 2922 additions and 0 deletions
+352
View File
@@ -0,0 +1,352 @@
// 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%\<appName>\license.cache
// - macOS: ~/Library/Application Support/<appName>/license.cache
// - Linux: ~/.config/<appName>/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
}
+172
View File
@@ -0,0 +1,172 @@
package client_test
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"gitea.tecamino.local/paadi/licenseServer/client"
"gitea.tecamino.local/paadi/licenseServer/client/hwid"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
const testMachineID = "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
var testSecret = []byte("test-cache-secret-32bytes-minimum!")
func newTestServer(valid bool, message string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"valid": valid, "message": message})
}))
}
func newClient(t *testing.T, serverURL string) *client.Client {
t.Helper()
c, err := client.New(client.Config{
ServerURL: serverURL,
LicenseKey: "TEST-1234-ABCD-5678",
AppName: "lictest_" + t.Name(),
GracePeriod: 7 * 24 * time.Hour,
CacheSecret: testSecret,
}, testMachineID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Remove(c.CachePath()) })
return c
}
func signCache(key []byte, licenseKey, machineID, ts string) string {
payload := licenseKey + "|" + machineID + "|" + ts
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
func writeCache(t *testing.T, path string, licenseKey, machineID string, validatedAt time.Time) {
t.Helper()
ts := validatedAt.UTC().Format(time.RFC3339)
entry := map[string]any{
"license_key": licenseKey,
"machine_id": machineID,
"last_validated": ts,
"sig": signCache(testSecret, licenseKey, machineID, ts),
}
os.MkdirAll(filepath.Dir(path), 0700)
data, _ := json.Marshal(entry)
os.WriteFile(path, data, 0600)
}
func TestOnlineValid(t *testing.T) {
srv := newTestServer(true, "")
defer srv.Close()
result := newClient(t, srv.URL).Check()
if !result.Valid || result.Offline {
t.Fatalf("expected online valid, got: %s", result.Message)
}
}
func TestOnlineInvalid(t *testing.T) {
srv := newTestServer(false, "license revoked")
defer srv.Close()
result := newClient(t, srv.URL).Check()
if result.Valid {
t.Fatal("expected invalid")
}
if result.Message != "license revoked" {
t.Fatalf("unexpected message: %s", result.Message)
}
}
func TestOnlineValidSavesCache(t *testing.T) {
srv := newTestServer(true, "")
defer srv.Close()
c := newClient(t, srv.URL)
c.Check()
if _, err := os.Stat(c.CachePath()); err != nil {
t.Fatal("expected cache file to be created after online validation")
}
}
func TestOfflineWithinGrace(t *testing.T) {
srv := newTestServer(true, "")
c := newClient(t, srv.URL)
c.Check()
srv.Close()
result := c.Check()
if !result.Valid || !result.Offline {
t.Fatalf("expected offline valid in grace period, got: %s", result.Message)
}
if result.GraceLeft <= 0 {
t.Fatal("expected positive grace remaining")
}
}
func TestOfflineExpiredGrace(t *testing.T) {
c := newClient(t, "http://127.0.0.1:19999")
writeCache(t, c.CachePath(), "TEST-1234-ABCD-5678", testMachineID, time.Now().Add(-8*24*time.Hour))
result := c.Check()
if result.Valid {
t.Fatal("expected invalid after grace period expired")
}
}
func TestOfflineNoServerNoCache(t *testing.T) {
result := newClient(t, "http://127.0.0.1:19999").Check()
if result.Valid {
t.Fatal("expected invalid with no server and no cache")
}
}
func TestOfflineWrongKey(t *testing.T) {
c := newClient(t, "http://127.0.0.1:19999")
writeCache(t, c.CachePath(), "DIFF-ERENT-KEY0-0000", testMachineID, time.Now())
result := c.Check()
if result.Valid {
t.Fatal("expected invalid when cached key does not match")
}
}
func TestOfflineWrongMachine(t *testing.T) {
c := newClient(t, "http://127.0.0.1:19999")
writeCache(t, c.CachePath(), "TEST-1234-ABCD-5678", "different-machine-id", time.Now())
result := c.Check()
if result.Valid {
t.Fatal("expected invalid when cached machine does not match")
}
}
func TestTamperedCache(t *testing.T) {
srv := newTestServer(true, "")
c := newClient(t, srv.URL)
c.Check()
srv.Close()
data, _ := os.ReadFile(c.CachePath())
var entry map[string]any
json.Unmarshal(data, &entry)
entry["last_validated"] = time.Now().Add(10 * time.Second).Format(time.RFC3339)
tampered, _ := json.Marshal(entry)
os.WriteFile(c.CachePath(), tampered, 0600)
result := c.Check()
if result.Valid {
t.Fatal("tampered cache should be rejected")
}
}
func TestGetMachineHash(t *testing.T) {
fp, err := hwid.Collect()
if err != nil {
t.Fatal(err)
}
t.Log(fp.Hash())
}
+195
View File
@@ -0,0 +1,195 @@
// Package hwid collects stable hardware identifiers to bind licenses
// to a specific machine. Works on Linux, Windows, and macOS.
package hwid
import (
"crypto/sha256"
"fmt"
"net"
"os"
"os/exec"
"runtime"
"strings"
)
// Fingerprint holds raw hardware identifiers collected from the system.
type Fingerprint struct {
MACAddresses []string // Physical network interface MACs
Hostname string
CPUInfo string // CPU model / identifier
MachineID string // OS-level machine UUID
OS string // runtime.GOOS
}
// Collect gathers hardware identifiers from the current machine.
func Collect() (*Fingerprint, error) {
fp := &Fingerprint{OS: runtime.GOOS}
fp.Hostname, _ = os.Hostname()
fp.MACAddresses = collectMACs()
fp.CPUInfo = collectCPU()
fp.MachineID = collectMachineID()
return fp, nil
}
// Hash returns a stable SHA-256 fingerprint string derived from the
// collected identifiers. Use this as the machine ID in your license claims.
// The hash is hex-encoded and always 64 characters long.
func (f *Fingerprint) Hash() string {
// Sort MACs for stability (interface order can vary)
macs := stableJoin(f.MACAddresses)
parts := []string{
"mac=" + macs,
"host=" + f.Hostname,
"cpu=" + f.CPUInfo,
"mid=" + f.MachineID,
"os=" + f.OS,
}
raw := strings.Join(parts, "|")
sum := sha256.Sum256([]byte(raw))
return fmt.Sprintf("%x", sum)
}
// --- MAC addresses -------------------------------------------------------
// collectMACs returns the hardware MAC addresses of all physical interfaces.
// Loopback and virtual interfaces are excluded.
func collectMACs() []string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var macs []string
for _, iface := range ifaces {
mac := iface.HardwareAddr.String()
if mac == "" {
continue
}
// Skip loopback, virtual, docker, VPN interfaces
name := strings.ToLower(iface.Name)
if isVirtual(name) {
continue
}
macs = append(macs, strings.ToUpper(mac))
}
return macs
}
func isVirtual(name string) bool {
prefixes := []string{"lo", "docker", "veth", "br-", "virbr", "vmnet", "vbox", "utun", "tun", "tap"}
for _, p := range prefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// --- CPU info ------------------------------------------------------------
func collectCPU() string {
switch runtime.GOOS {
case "linux":
return readLinuxCPU()
case "darwin":
return runCmd("sysctl", "-n", "machdep.cpu.brand_string")
case "windows":
return runCmd("wmic", "cpu", "get", "ProcessorId", "/value")
}
return ""
}
func readLinuxCPU() string {
data, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "model name") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}
// --- Machine ID ----------------------------------------------------------
// collectMachineID reads the OS-level machine UUID:
// - Linux: /etc/machine-id (systemd) or /var/lib/dbus/machine-id
// - macOS: ioreg hardware UUID
// - Windows: registry MachineGuid
func collectMachineID() string {
switch runtime.GOOS {
case "linux":
return readLinuxMachineID()
case "darwin":
out := runCmd("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
return extractIORegValue(out, "IOPlatformUUID")
case "windows":
out := runCmd("reg", "query",
`HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
return parseRegOutput(out)
}
return ""
}
func readLinuxMachineID() string {
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
data, err := os.ReadFile(path)
if err == nil {
return strings.TrimSpace(string(data))
}
}
return ""
}
func extractIORegValue(output, key string) string {
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, key) {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.Trim(strings.TrimSpace(parts[1]), `"`)
}
}
}
return ""
}
func parseRegOutput(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "MachineGuid") {
parts := strings.Fields(line)
if len(parts) >= 3 {
return parts[len(parts)-1]
}
}
}
return ""
}
// --- Helpers -------------------------------------------------------------
func runCmd(name string, args ...string) string {
out, err := exec.Command(name, args...).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func stableJoin(ss []string) string {
// Simple insertion sort for stability without importing sort
a := append([]string(nil), ss...)
for i := 1; i < len(a); i++ {
for j := i; j > 0 && a[j] < a[j-1]; j-- {
a[j], a[j-1] = a[j-1], a[j]
}
}
return strings.Join(a, ",")
}
+309
View File
@@ -0,0 +1,309 @@
// Package token provides license token generation and validation.
// Supports two license modes:
// - Expiry-based: token is valid until a specific date
// - Usage-based: token is valid for a fixed number of uses
package token
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
)
// LicenseType defines the kind of license restriction.
type LicenseType string
const (
TypeExpiry LicenseType = "expiry" // Valid until end date
TypeUsage LicenseType = "usage" // Valid for N uses
)
// Claims holds the license payload embedded in the token.
type Claims struct {
LicenseID string `json:"lid"` // Unique license identifier
Type LicenseType `json:"type"` // expiry or usage
Subject string `json:"sub"` // Who the license is for (app name, user, etc.)
IssuedAt int64 `json:"iat"` // Unix timestamp of issue
ExpiresAt *int64 `json:"exp,omitempty"` // Unix timestamp — set for TypeExpiry
MaxUses *int64 `json:"max,omitempty"` // Max allowed uses — set for TypeUsage
UsedCount int64 `json:"used"` // Current use count (for TypeUsage)
MachineID string `json:"mid,omitempty"` // Hardware fingerprint hash (optional binding)
Metadata map[string]string `json:"meta,omitempty"`
}
// Token is the signed, serialized license string.
type Token string
// ValidationResult is returned by Validate.
type ValidationResult struct {
Valid bool
Claims *Claims
Remaining *int64 // Remaining uses (TypeUsage only)
ExpiresAt *time.Time // Expiry time (TypeExpiry only)
Error error
}
// Generator creates and signs license tokens.
type Generator struct {
secret []byte
}
// NewGenerator creates a Generator with the given HMAC secret.
// The secret should be at least 32 bytes of random data in production.
func NewGenerator(secret []byte) (*Generator, error) {
if len(secret) < 16 {
return nil, errors.New("license: secret must be at least 16 bytes")
}
return &Generator{secret: secret}, nil
}
// IssueExpiry creates a license token valid until the given end date.
func (g *Generator) IssueExpiry(subject string, expiresAt time.Time, meta map[string]string) (Token, error) {
exp := expiresAt.Unix()
claims := &Claims{
LicenseID: newID(),
Type: TypeExpiry,
Subject: subject,
IssuedAt: time.Now().Unix(),
ExpiresAt: &exp,
Metadata: meta,
}
return g.sign(claims)
}
// IssueUsage creates a license token valid for maxUses activations.
func (g *Generator) IssueUsage(subject string, maxUses int64, meta map[string]string) (Token, error) {
if maxUses <= 0 {
return "", errors.New("license: maxUses must be > 0")
}
claims := &Claims{
LicenseID: newID(),
Type: TypeUsage,
Subject: subject,
IssuedAt: time.Now().Unix(),
MaxUses: &maxUses,
UsedCount: 0,
Metadata: meta,
}
return g.sign(claims)
}
// IssueExpiryBound creates an expiry license locked to a specific machine.
func (g *Generator) IssueExpiryBound(subject string, expiresAt time.Time, machineID string, meta map[string]string) (Token, error) {
exp := expiresAt.Unix()
claims := &Claims{
LicenseID: newID(),
Type: TypeExpiry,
Subject: subject,
IssuedAt: time.Now().Unix(),
ExpiresAt: &exp,
MachineID: machineID,
Metadata: meta,
}
return g.sign(claims)
}
// IssueUsageBound creates a usage license locked to a specific machine.
func (g *Generator) IssueUsageBound(subject string, maxUses int64, machineID string, meta map[string]string) (Token, error) {
if maxUses <= 0 {
return "", errors.New("license: maxUses must be > 0")
}
claims := &Claims{
LicenseID: newID(),
Type: TypeUsage,
Subject: subject,
IssuedAt: time.Now().Unix(),
MaxUses: &maxUses,
MachineID: machineID,
Metadata: meta,
}
return g.sign(claims)
}
// sign serializes claims and appends an HMAC signature.
func (g *Generator) sign(c *Claims) (Token, error) {
payload, err := json.Marshal(c)
if err != nil {
return "", fmt.Errorf("license: marshal failed: %w", err)
}
b64 := base64.RawURLEncoding.EncodeToString(payload)
sig := g.hmac(b64)
return Token(b64 + "." + sig), nil
}
// hmac returns a base64url-encoded HMAC-SHA256 of data.
func (g *Generator) hmac(data string) string {
mac := hmac.New(sha256.New, g.secret)
mac.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// -----------------------------------------------------------------------
// Reader validates and (for usage licenses) tracks consumption.
// -----------------------------------------------------------------------
// Reader validates tokens and tracks usage counts.
type Reader struct {
secret []byte
mu sync.Mutex
usageLedger map[string]int64 // licenseID -> used count (in-memory; swap for DB in prod)
}
// NewReader creates a Reader with the same secret used by the Generator.
func NewReader(secret []byte) (*Reader, error) {
if len(secret) < 16 {
return nil, errors.New("license: secret must be at least 16 bytes")
}
return &Reader{
secret: secret,
usageLedger: make(map[string]int64),
}, nil
}
// Validate checks the token signature and license constraints.
// Pass machineID (hwid.Fingerprint.Hash()) to enforce hardware binding, or "" to skip.
// It does NOT consume a usage unit - call Consume for that.
func (r *Reader) Validate(t Token, machineID string) ValidationResult {
claims, err := r.parse(t)
if err != nil {
return ValidationResult{Valid: false, Error: err}
}
// Hardware binding check
if claims.MachineID != "" {
if machineID == "" {
return ValidationResult{Valid: false, Error: errors.New("license: cannot verify machine binding"), Claims: claims}
}
if !hmac.Equal([]byte(claims.MachineID), []byte(machineID)) {
return ValidationResult{Valid: false, Error: errors.New("license: bound to a different machine"), Claims: claims}
}
}
switch claims.Type {
case TypeExpiry:
if claims.ExpiresAt == nil {
return ValidationResult{Valid: false, Error: errors.New("license: missing expiry date"), Claims: claims}
}
exp := time.Unix(*claims.ExpiresAt, 0)
if time.Now().After(exp) {
return ValidationResult{
Valid: false,
Claims: claims,
ExpiresAt: &exp,
Error: fmt.Errorf("license: expired on %s", exp.Format(time.DateOnly)),
}
}
return ValidationResult{Valid: true, Claims: claims, ExpiresAt: &exp}
case TypeUsage:
if claims.MaxUses == nil {
return ValidationResult{Valid: false, Error: errors.New("license: missing max uses"), Claims: claims}
}
r.mu.Lock()
used := r.usageLedger[claims.LicenseID]
r.mu.Unlock()
remaining := *claims.MaxUses - used
if remaining <= 0 {
return ValidationResult{
Valid: false,
Claims: claims,
Remaining: &remaining,
Error: fmt.Errorf("license: usage limit of %d reached", *claims.MaxUses),
}
}
return ValidationResult{Valid: true, Claims: claims, Remaining: &remaining}
}
return ValidationResult{Valid: false, Error: fmt.Errorf("license: unknown type %q", claims.Type)}
}
// Consume validates the token, records one usage unit, and returns the result.
// Pass machineID to enforce hardware binding, or "" to skip.
func (r *Reader) Consume(t Token, machineID string) (ValidationResult, error) {
result := r.Validate(t, machineID)
if !result.Valid {
return result, result.Error
}
if result.Claims.Type != TypeUsage {
return result, nil // expiry licenses don't need consumption
}
r.mu.Lock()
defer r.mu.Unlock()
used := r.usageLedger[result.Claims.LicenseID] + 1
r.usageLedger[result.Claims.LicenseID] = used
remaining := *result.Claims.MaxUses - used
result.Remaining = &remaining
return result, nil
}
// LoadUsage seeds the reader with persisted usage counts (e.g. from a DB on startup).
func (r *Reader) LoadUsage(licenseID string, usedCount int64) {
r.mu.Lock()
defer r.mu.Unlock()
r.usageLedger[licenseID] = usedCount
}
// UsageSnapshot returns a copy of the current usage ledger for persistence.
func (r *Reader) UsageSnapshot() map[string]int64 {
r.mu.Lock()
defer r.mu.Unlock()
snap := make(map[string]int64, len(r.usageLedger))
for k, v := range r.usageLedger {
snap[k] = v
}
return snap
}
// parse verifies the signature and deserializes claims.
func (r *Reader) parse(t Token) (*Claims, error) {
parts := strings.SplitN(string(t), ".", 2)
if len(parts) != 2 {
return nil, errors.New("license: malformed token")
}
b64, sig := parts[0], parts[1]
// Verify signature first (constant-time)
expected := r.hmac(b64)
if !hmac.Equal([]byte(sig), []byte(expected)) {
return nil, errors.New("license: invalid signature")
}
payload, err := base64.RawURLEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("license: decode failed: %w", err)
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("license: unmarshal failed: %w", err)
}
return &claims, nil
}
// hmac returns a base64url-encoded HMAC-SHA256 of data.
func (r *Reader) hmac(data string) string {
mac := hmac.New(sha256.New, r.secret)
mac.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
// newID generates a random 16-byte hex license ID.
func newID() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
+215
View File
@@ -0,0 +1,215 @@
package token_test
import (
"testing"
"time"
"gitea.tecamino.local/paadi/licenseServer/client/hwid"
"gitea.tecamino.local/paadi/licenseServer/client/token"
)
var secret = []byte("super-secret-key-32-bytes-minimum!")
func newPair(t *testing.T) (*token.Generator, *token.Reader) {
t.Helper()
g, err := token.NewGenerator(secret)
if err != nil {
t.Fatal(err)
}
r, err := token.NewReader(secret)
if err != nil {
t.Fatal(err)
}
return g, r
}
// --- Expiry license tests ---
func TestExpiryLicense_Valid(t *testing.T) {
g, r := newPair(t)
tok, err := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
if err != nil {
t.Fatal(err)
}
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
result := r.Validate(tok, machineID)
if !result.Valid {
t.Fatalf("expected valid, got: %v", result.Error)
}
if result.ExpiresAt == nil {
t.Fatal("expected ExpiresAt to be set")
}
}
func TestExpiryLicense_Expired(t *testing.T) {
g, r := newPair(t)
tok, err := g.IssueExpiry("myapp", time.Now().Add(-1*time.Hour), nil)
if err != nil {
t.Fatal(err)
}
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
result := r.Validate(tok, machineID)
if result.Valid {
t.Fatal("expected invalid for expired license")
}
}
// --- Usage license tests ---
func TestUsageLicense_Valid(t *testing.T) {
g, r := newPair(t)
tok, err := g.IssueUsage("myapp", 5, nil)
if err != nil {
t.Fatal(err)
}
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
result := r.Validate(tok, machineID)
if !result.Valid {
t.Fatalf("expected valid: %v", result.Error)
}
if *result.Remaining != 5 {
t.Fatalf("expected 5 remaining, got %d", *result.Remaining)
}
}
func TestUsageLicense_Consume(t *testing.T) {
g, r := newPair(t)
tok, err := g.IssueUsage("myapp", 3, nil)
if err != nil {
t.Fatal(err)
}
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
for i := int64(2); i >= 0; i-- {
res, err := r.Consume(tok, machineID)
if err != nil {
t.Fatalf("consume failed: %v", err)
}
if *res.Remaining != i {
t.Fatalf("expected %d remaining, got %d", i, *res.Remaining)
}
}
// 4th consume should fail
_, err = r.Consume(tok, machineID)
if err == nil {
t.Fatal("expected error after exhausting usage")
}
}
func TestUsageLicense_Exhausted(t *testing.T) {
g, r := newPair(t)
tok, err := g.IssueUsage("myapp", 2, nil)
if err != nil {
t.Fatal(err)
}
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
r.Consume(tok, machineID)
r.Consume(tok, machineID)
result := r.Validate(tok, machineID)
if result.Valid {
t.Fatal("expected invalid after exhausting uses")
}
}
// --- Tamper / signature tests ---
func TestTamperedToken(t *testing.T) {
g, r := newPair(t)
tok, _ := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
tampered := token.Token(string(tok) + "x")
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
result := r.Validate(tampered, machineID)
if result.Valid {
t.Fatal("tampered token should be invalid")
}
}
func TestWrongSecret(t *testing.T) {
g, _ := newPair(t)
tok, _ := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
// Reader with different secret
r2, _ := token.NewReader([]byte("different-secret-key-32-bytes-xx!"))
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
result := r2.Validate(tok, machineID)
if result.Valid {
t.Fatal("token signed with different secret should be invalid")
}
}
// --- Usage persistence ---
func TestUsageLedger_Persistence(t *testing.T) {
g, r := newPair(t)
tok, _ := g.IssueUsage("myapp", 10, nil)
fp, err := hwid.Collect()
if err != nil {
panic(err)
}
machineID := fp.Hash()
res, _ := r.Consume(tok, machineID)
res, _ = r.Consume(tok, machineID)
_ = res
snap := r.UsageSnapshot()
// Simulate restart: new reader, load snapshot
r2, _ := token.NewReader(secret)
for id, count := range snap {
r2.LoadUsage(id, count)
}
result := r2.Validate(tok, machineID)
if !result.Valid {
t.Fatalf("expected valid after reload: %v", result.Error)
}
if *result.Remaining != 8 {
t.Fatalf("expected 8 remaining after reloading 2 uses, got %d", *result.Remaining)
}
}