Files
2026-05-05 08:44:55 +00:00

310 lines
9.4 KiB
Go

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