Files
aura-crm/production/backend/tools/gen-license/main.go
T
root 56ffca767d feat: offline license/trial system
7-day full-access trial, then gated behind an Ed25519-signed license key
(internal/license) verified entirely offline — no phone-home dependency.
Middleware 402s non-exempt API routes once the trial lapses with no valid
key; frontend shows a countdown banner in the last 3 days and a blocking
overlay once actually gated, both pointing at the new Settings -> Лицензия
tab. tools/gen-license mints keys for the vendor side (never ships the
private key).
2026-08-21 18:44:54 +00:00

93 lines
2.9 KiB
Go

// Command gen-license mints signed license keys for Aura CRM (see
// internal/license's package doc for the format/threat model). This is a
// vendor-side tool — it needs the Ed25519 PRIVATE key, which must never
// ship inside the product itself or this repo. Run it only on a machine
// that holds that key, e.g.:
//
// go run ./tools/gen-license -customer "ООО Ромашка" -days 365 \
// -key /root/.aura-license/private_key.hex
package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
)
// Mirrors internal/license.Payload field-for-field — duplicated rather
// than imported so this tool has zero dependency on the rest of the
// module and can be copy-pasted out to a vendor-only machine on its own.
type payload struct {
Customer string `json:"customer"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func main() {
customer := flag.String("customer", "", "customer/business name this license is issued to (required)")
days := flag.Int("days", 0, "days until expiry from now; 0 = perpetual license")
keyPath := flag.String("key", "", "path to the hex-encoded Ed25519 private key (required)")
flag.Parse()
if *customer == "" || *keyPath == "" {
fmt.Fprintln(os.Stderr, "usage: gen-license -customer NAME -key PATH [-days N]")
os.Exit(1)
}
priv, err := loadPrivateKey(*keyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "gen-license: %v\n", err)
os.Exit(1)
}
p := payload{Customer: *customer, IssuedAt: time.Now().UTC()}
if *days > 0 {
exp := p.IssuedAt.AddDate(0, 0, *days)
p.ExpiresAt = &exp
}
key, err := sign(priv, p)
if err != nil {
fmt.Fprintf(os.Stderr, "gen-license: %v\n", err)
os.Exit(1)
}
fmt.Println(key)
}
func loadPrivateKey(path string) (ed25519.PrivateKey, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading key file: %w", err)
}
decoded, err := hex.DecodeString(strings.TrimSpace(string(raw)))
if err != nil {
return nil, fmt.Errorf("key file is not valid hex: %w", err)
}
if len(decoded) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("key file has %d bytes, expected %d (ed25519 private key)", len(decoded), ed25519.PrivateKeySize)
}
return ed25519.PrivateKey(decoded), nil
}
// sign encodes payload the same way internal/license.Parse decodes it —
// signature covers the base64 payload TEXT, not the raw JSON bytes, so
// both sides agree on exactly one byte sequence. See that package's Parse
// doc comment for why.
func sign(priv ed25519.PrivateKey, p payload) (string, error) {
payloadJSON, err := json.Marshal(p)
if err != nil {
return "", fmt.Errorf("encoding payload: %w", err)
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
sig := ed25519.Sign(priv, []byte(payloadB64))
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return payloadB64 + "." + sigB64, nil
}