Files
2026-07-20 21:41:51 +02:00

432 lines
16 KiB
Go

package platform
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/b1tsblog/license-platform/pkg/licensekit"
)
type IssueInput struct {
CustomerUserID string `json:"customerUserId,omitempty"`
Customer string `json:"customer"`
Product string `json:"product"`
Edition string `json:"edition"`
Features []string `json:"features,omitempty"`
Limits map[string]int64 `json:"limits,omitempty"`
Domains []string `json:"domains,omitempty"`
InstanceIDs []string `json:"instanceIds,omitempty"`
Mode string `json:"mode"`
Days int `json:"days"`
LeaseMinutes int `json:"leaseMinutes"`
GraceHours int `json:"graceHours"`
}
type LicenseView struct {
LicenseID string `json:"licenseId"`
Issuer string `json:"issuer"`
Customer string `json:"customer"`
CustomerUserID string `json:"customerUserId,omitempty"`
Product string `json:"product"`
Edition string `json:"edition"`
Features []string `json:"features,omitempty"`
Limits map[string]int64 `json:"limits,omitempty"`
Domains []string `json:"domains,omitempty"`
InstanceIDs []string `json:"instanceIds,omitempty"`
Mode string `json:"mode"`
ServerURL string `json:"serverUrl,omitempty"`
ExpiresAt int64 `json:"expiresAt"`
Revoked bool `json:"revoked"`
Reason string `json:"reason,omitempty"`
Token string `json:"token,omitempty"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
type validateRequest struct {
Token string `json:"token"`
Product string `json:"product"`
BaseURL string `json:"baseUrl"`
Host string `json:"host,omitempty"`
InstanceID string `json:"instanceId,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
}
type validateResponse struct {
Valid bool `json:"valid"`
LeaseToken string `json:"leaseToken,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
func (s *Server) generateKeys(issuerKeyID, leaseKeyID string) error {
issuerKeyID = strings.TrimSpace(issuerKeyID)
leaseKeyID = strings.TrimSpace(leaseKeyID)
if !validKeyID(issuerKeyID) || !validKeyID(leaseKeyID) {
return errors.New("key IDs must be 1-120 URL-safe characters")
}
issuerPub, issuerPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
leasePub, leasePriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
issuerCipher, err := s.vault.Encrypt(licensekit.EncodeKey(issuerPriv))
if err != nil {
return err
}
leaseCipher, err := s.vault.Encrypt(licensekit.EncodeKey(leasePriv))
if err != nil {
return err
}
return s.store.SetKeysOnce(KeySet{
IssuerKeyID: issuerKeyID, IssuerPublicKey: licensekit.EncodeKey(issuerPub), IssuerPrivateCipher: issuerCipher,
LeaseKeyID: leaseKeyID, LeasePublicKey: licensekit.EncodeKey(leasePub), LeasePrivateCipher: leaseCipher,
})
}
func (s *Server) importKeys(issuerKeyID, issuerPrivateEncoded, leaseKeyID, leasePrivateEncoded string) error {
issuerKeyID = strings.TrimSpace(issuerKeyID)
leaseKeyID = strings.TrimSpace(leaseKeyID)
if !validKeyID(issuerKeyID) || !validKeyID(leaseKeyID) {
return errors.New("key IDs must be 1-120 URL-safe characters")
}
issuerPrivate, err := licensekit.DecodePrivateKey(strings.TrimSpace(issuerPrivateEncoded))
if err != nil {
return fmt.Errorf("issuer private key: %w", err)
}
leasePrivate, err := licensekit.DecodePrivateKey(strings.TrimSpace(leasePrivateEncoded))
if err != nil {
return fmt.Errorf("lease private key: %w", err)
}
issuerCipher, err := s.vault.Encrypt(licensekit.EncodeKey(issuerPrivate))
if err != nil {
return err
}
leaseCipher, err := s.vault.Encrypt(licensekit.EncodeKey(leasePrivate))
if err != nil {
return err
}
return s.store.SetKeysOnce(KeySet{
IssuerKeyID: issuerKeyID, IssuerPublicKey: licensekit.EncodeKey(issuerPrivate.Public().(ed25519.PublicKey)), IssuerPrivateCipher: issuerCipher,
LeaseKeyID: leaseKeyID, LeasePublicKey: licensekit.EncodeKey(leasePrivate.Public().(ed25519.PublicKey)), LeasePrivateCipher: leaseCipher,
})
}
func (s *Server) trustStore() (licensekit.TrustStore, error) {
keys, ok := s.store.KeySet()
if !ok {
return licensekit.TrustStore{}, errors.New("key pairs have not been initialized")
}
store := licensekit.NewTrustStore()
store.LicenseKeys[keys.IssuerKeyID] = keys.IssuerPublicKey
store.LeaseKeys[keys.LeaseKeyID] = keys.LeasePublicKey
return store, nil
}
func (s *Server) issueLicense(actor User, input IssueInput) (LicenseRecord, string, error) {
if actor.Role != RoleAdmin && actor.Role != RoleReseller {
return LicenseRecord{}, "", errors.New("issuer role required")
}
keys, ok := s.store.KeySet()
if !ok {
return LicenseRecord{}, "", errors.New("key pairs have not been initialized")
}
input.Customer = strings.TrimSpace(input.Customer)
input.Product = strings.TrimSpace(input.Product)
input.Edition = strings.TrimSpace(input.Edition)
if input.Customer == "" || input.Product == "" || input.Edition == "" {
return LicenseRecord{}, "", errors.New("customer, product and edition are required")
}
if len(input.Customer) > 300 || len(input.Product) > 200 || len(input.Edition) > 100 {
return LicenseRecord{}, "", errors.New("customer, product or edition is too long")
}
for _, value := range append(append([]string{}, input.Features...), append(input.Domains, input.InstanceIDs...)...) {
if len(strings.TrimSpace(value)) > 300 {
return LicenseRecord{}, "", errors.New("feature, domain or instance value is too long")
}
}
mode, err := licensekit.ParseMode(input.Mode)
if err != nil {
return LicenseRecord{}, "", err
}
if input.Days < 1 || input.Days > 3650 {
return LicenseRecord{}, "", errors.New("days must be between 1 and 3650")
}
if input.LeaseMinutes <= 0 {
input.LeaseMinutes = 60
}
if input.LeaseMinutes > 1440 || input.GraceHours < 0 || input.GraceHours > 8760 {
return LicenseRecord{}, "", errors.New("lease or grace duration is outside the allowed range")
}
if input.CustomerUserID != "" {
customerUser, ok := s.store.GetUser(input.CustomerUserID)
if !ok || customerUser.Role != RoleCustomer || !customerUser.Active {
return LicenseRecord{}, "", errors.New("customer portal account was not found")
}
if actor.Role == RoleReseller && customerUser.ParentUserID != actor.ID {
return LicenseRecord{}, "", errors.New("customer portal account is not assigned to this reseller")
}
}
privateEncoded, err := s.vault.Decrypt(keys.IssuerPrivateCipher)
if err != nil {
return LicenseRecord{}, "", err
}
privateKey, err := licensekit.DecodePrivateKey(privateEncoded)
if err != nil {
return LicenseRecord{}, "", err
}
now := time.Now().UTC()
licenseID := newID("lic")
serverURL := ""
if mode != licensekit.ModeOffline {
serverURL = s.cfg.PublicURL
}
claims := licensekit.Claims{
Version: 1, LicenseID: licenseID, Issuer: actor.DisplayName, Customer: input.Customer,
Product: input.Product, Edition: input.Edition, Features: unique(input.Features), Limits: input.Limits,
Domains: unique(input.Domains), InstanceIDs: unique(input.InstanceIDs), IssuedAt: now.Unix(),
ExpiresAt: now.Add(time.Duration(input.Days) * 24 * time.Hour).Unix(),
Verification: licensekit.VerificationPolicy{Mode: mode, LeaseTTLSeconds: int64(time.Duration(input.LeaseMinutes) * time.Minute / time.Second), OfflineGraceSeconds: int64(time.Duration(input.GraceHours) * time.Hour / time.Second), ServerURL: serverURL},
Metadata: map[string]string{"issuedByUserId": actor.ID},
}
token, err := licensekit.SignLicense(privateKey, keys.IssuerKeyID, claims)
if err != nil {
return LicenseRecord{}, "", err
}
ciphertext, err := s.vault.Encrypt(token)
if err != nil {
return LicenseRecord{}, "", err
}
record := LicenseRecord{
LicenseID: licenseID, TokenCipher: ciphertext, TokenHash: licensekit.TokenHash(token), Issuer: claims.Issuer,
Customer: claims.Customer, CustomerUserID: input.CustomerUserID, IssuedByUserID: actor.ID,
Product: claims.Product, Edition: claims.Edition, Features: claims.Features, Limits: claims.Limits,
Domains: claims.Domains, InstanceIDs: claims.InstanceIDs, Mode: string(mode), ServerURL: serverURL, ExpiresAt: claims.ExpiresAt,
}
if err := s.store.PutLicense(record); err != nil {
return LicenseRecord{}, "", err
}
stored, _ := s.store.GetLicense(licenseID)
return stored, token, nil
}
func (s *Server) importLicense(actor User, token, customerUserID string) (LicenseRecord, error) {
if actor.Role != RoleAdmin && actor.Role != RoleReseller {
return LicenseRecord{}, errors.New("issuer role required")
}
token = strings.TrimSpace(token)
if token == "" {
return LicenseRecord{}, errors.New("license token is required")
}
trust, err := s.trustStore()
if err != nil {
return LicenseRecord{}, err
}
verified, err := licensekit.VerifyLicense(trust, token, time.Now().UTC())
if err != nil {
return LicenseRecord{}, err
}
claims := verified.Claims
if existing, ok := s.store.GetLicense(claims.LicenseID); ok {
if existing.TokenHash == licensekit.TokenHash(token) {
return existing, nil
}
return LicenseRecord{}, errors.New("license ID already exists with another token")
}
if customerUserID != "" {
customer, ok := s.store.GetUser(customerUserID)
if !ok || customer.Role != RoleCustomer || !customer.Active {
return LicenseRecord{}, errors.New("customer portal account was not found")
}
if actor.Role == RoleReseller && customer.ParentUserID != actor.ID {
return LicenseRecord{}, errors.New("customer portal account is not assigned to this reseller")
}
}
ciphertext, err := s.vault.Encrypt(token)
if err != nil {
return LicenseRecord{}, err
}
record := LicenseRecord{
LicenseID: claims.LicenseID, TokenCipher: ciphertext, TokenHash: licensekit.TokenHash(token),
Issuer: claims.Issuer, Customer: claims.Customer, CustomerUserID: customerUserID, IssuedByUserID: actor.ID,
Product: claims.Product, Edition: claims.Edition, Features: claims.Features, Limits: claims.Limits,
Domains: claims.Domains, InstanceIDs: claims.InstanceIDs, Mode: string(claims.Verification.Mode),
ServerURL: strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"), ExpiresAt: claims.ExpiresAt,
}
if err := s.store.PutLicense(record); err != nil {
return LicenseRecord{}, err
}
stored, _ := s.store.GetLicense(record.LicenseID)
return stored, nil
}
func (s *Server) tokenFor(record LicenseRecord) (string, error) {
return s.vault.Decrypt(record.TokenCipher)
}
func (s *Server) validateLicense(request validateRequest) (validateResponse, error) {
store, err := s.trustStore()
if err != nil {
return validateResponse{Reason: err.Error()}, err
}
now := time.Now().UTC()
verified, err := licensekit.VerifyLicense(store, request.Token, now)
if err != nil {
return validateResponse{Reason: err.Error()}, err
}
claims := verified.Claims
if err := licensekit.ValidateLicenseContext(claims, request.Product, request.BaseURL, request.InstanceID); err != nil {
return validateResponse{Reason: err.Error()}, err
}
record, ok := s.store.GetLicense(claims.LicenseID)
if !ok {
err := errors.New("license is not registered")
return validateResponse{Reason: err.Error()}, err
}
if record.TokenHash != licensekit.TokenHash(request.Token) {
err := errors.New("registered token does not match")
return validateResponse{Reason: err.Error()}, err
}
if record.Revoked {
reason := "license is revoked"
if record.RevocationReason != "" {
reason += ": " + record.RevocationReason
}
return validateResponse{Reason: reason}, errors.New(reason)
}
keys, _ := s.store.KeySet()
leaseEncoded, err := s.vault.Decrypt(keys.LeasePrivateCipher)
if err != nil {
return validateResponse{Reason: "lease signing unavailable"}, err
}
leaseKey, err := licensekit.DecodePrivateKey(leaseEncoded)
if err != nil {
return validateResponse{Reason: "lease signing unavailable"}, err
}
ttl := s.cfg.DefaultLeaseTTL
if claims.Verification.LeaseTTLSeconds > 0 {
ttl = time.Duration(claims.Verification.LeaseTTLSeconds) * time.Second
}
if ttl > s.cfg.MaxLeaseTTL {
ttl = s.cfg.MaxLeaseTTL
}
if remaining := time.Until(time.Unix(claims.ExpiresAt, 0)); ttl > remaining {
ttl = remaining
}
if ttl <= 0 {
return validateResponse{Reason: "license has expired"}, errors.New("license has expired")
}
host, _ := licensekit.HostFromBaseURL(request.BaseURL)
leaseClaims := licensekit.LeaseClaims{Version: 1, LeaseID: newID("lease"), LicenseID: claims.LicenseID, Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features, Host: host, InstanceID: request.InstanceID, IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix()}
lease, err := licensekit.SignLease(leaseKey, keys.LeaseKeyID, leaseClaims)
if err != nil {
return validateResponse{Reason: "lease signing failed"}, err
}
return validateResponse{Valid: true, LeaseToken: lease, ExpiresAt: time.Unix(leaseClaims.ExpiresAt, 0).UTC().Format(time.RFC3339)}, nil
}
func licenseView(record LicenseRecord, token string) LicenseView {
return LicenseView{LicenseID: record.LicenseID, Issuer: record.Issuer, Customer: record.Customer, CustomerUserID: record.CustomerUserID, Product: record.Product, Edition: record.Edition, Features: record.Features, Limits: record.Limits, Domains: record.Domains, InstanceIDs: record.InstanceIDs, Mode: record.Mode, ServerURL: record.ServerURL, ExpiresAt: record.ExpiresAt, Revoked: record.Revoked, Reason: record.RevocationReason, Token: token, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
}
func parseIssueForm(values url.Values) (IssueInput, error) {
days, err := parseBoundedInt(values.Get("days"), 1, 3650)
if err != nil {
return IssueInput{}, fmt.Errorf("days: %w", err)
}
leaseMinutes, err := parseBoundedInt(values.Get("leaseMinutes"), 1, 1440)
if err != nil {
return IssueInput{}, fmt.Errorf("lease minutes: %w", err)
}
graceHours, err := parseBoundedInt(values.Get("graceHours"), 0, 8760)
if err != nil {
return IssueInput{}, fmt.Errorf("grace hours: %w", err)
}
limits, err := parseLimits(values.Get("limits"))
if err != nil {
return IssueInput{}, err
}
return IssueInput{CustomerUserID: strings.TrimSpace(values.Get("customerUserId")), Customer: values.Get("customer"), Product: values.Get("product"), Edition: values.Get("edition"), Features: csv(values.Get("features")), Limits: limits, Domains: csv(values.Get("domains")), InstanceIDs: csv(values.Get("instances")), Mode: values.Get("mode"), Days: days, LeaseMinutes: leaseMinutes, GraceHours: graceHours}, nil
}
func parseBoundedInt(value string, min, max int) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || n < min || n > max {
return 0, fmt.Errorf("must be between %d and %d", min, max)
}
return n, nil
}
func parseLimits(value string) (map[string]int64, error) {
limits := map[string]int64{}
for _, line := range strings.Split(value, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
return nil, fmt.Errorf("invalid limit %q; expected name=value", line)
}
value, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if err != nil || value < 0 {
return nil, fmt.Errorf("invalid limit %q", line)
}
limits[strings.TrimSpace(parts[0])] = value
}
return limits, nil
}
func csv(value string) []string { return unique(strings.Split(value, ",")) }
func unique(values []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
sort.Strings(out)
return out
}
func newID(prefix string) string {
raw, err := randomToken(12)
if err != nil {
return fmt.Sprintf("%s_%d", prefix, time.Now().UTC().UnixNano())
}
return prefix + "_" + raw
}
func trustStoreJSON(store licensekit.TrustStore) string {
data, _ := json.MarshalIndent(store, "", " ")
return string(data)
}
func validKeyID(value string) bool {
if len(value) < 1 || len(value) > 120 {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == ':' {
continue
}
return false
}
return true
}