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
+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)
}
}