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