310 lines
9.4 KiB
Go
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)
|
|
}
|