432 lines
16 KiB
Go
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
|
|
}
|