initital commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user