56ffca767d
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).
93 lines
2.9 KiB
Go
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
|
|
}
|