mirror of
https://github.com/luxfi/license.git
synced 2026-10-30 16:21:14 +00:00
- Stdlib-only deterministic binary token format (no JWT / CBOR dep). - Go API: VerifyToken, LoadFromEnv, HasFeature, HasFeatureOrDie. - Stable C ABI + C++17 RAII wrapper for non-Go commercial binaries (luxcpp/cevm). - cmd/lux-license: keygen / issue / inspect / verify. - Embedded development key; warns once at runtime until production key swap. - 22 Go tests + 1 cgo round-trip test, all passing.
352 lines
9.6 KiB
Go
352 lines
9.6 KiB
Go
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Command lux-license issues, inspects, and verifies Lux commercial license
|
|
// tokens. The runtime API used by Lux binaries lives in the parent package;
|
|
// this CLI is for operators (Lux key custodians) and customers.
|
|
//
|
|
// lux-license issue --customer "Acme Inc" --expiry 2027-12-31 --scope gpu,dex
|
|
// lux-license inspect <base64-token>
|
|
// lux-license verify <base64-token>
|
|
// lux-license keygen --out ./issuer.key
|
|
//
|
|
// `issue` requires a private key (--private-key path or --dev for the embedded
|
|
// development key). All other commands are read-only and require no key.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luxfi/license"
|
|
)
|
|
|
|
const usage = `lux-license — issue, inspect, and verify Lux commercial license tokens.
|
|
|
|
Subcommands:
|
|
issue Sign a new license token. Requires --private-key or --dev.
|
|
inspect Decode and print a token (without verifying signature).
|
|
verify Verify a token's signature and expiry. Exit 0 on success.
|
|
keygen Generate a new Ed25519 issuer keypair.
|
|
help Print this message.
|
|
|
|
Run 'lux-license <subcommand> -h' for subcommand flags.
|
|
`
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Fprint(os.Stderr, usage)
|
|
os.Exit(2)
|
|
}
|
|
switch os.Args[1] {
|
|
case "issue":
|
|
cmdIssue(os.Args[2:])
|
|
case "inspect":
|
|
cmdInspect(os.Args[2:])
|
|
case "verify":
|
|
cmdVerify(os.Args[2:])
|
|
case "keygen":
|
|
cmdKeygen(os.Args[2:])
|
|
case "help", "-h", "--help":
|
|
fmt.Print(usage)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n%s", os.Args[1], usage)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
// issue ----------------------------------------------------------------------
|
|
|
|
func cmdIssue(args []string) {
|
|
fs := flag.NewFlagSet("issue", flag.ExitOnError)
|
|
customer := fs.String("customer", "", "customer ID (required)")
|
|
licenseID := fs.String("license-id", "", "unique license ID (default: auto-generated)")
|
|
scope := fs.String("scope", "", "comma-separated scopes (e.g. gpu,dex,fpga)")
|
|
feat := fs.String("features", "", "comma-separated key=value features (e.g. tier=enterprise,max_nodes=10)")
|
|
notes := fs.String("notes", "", "free-form notes")
|
|
expiry := fs.String("expiry", "", "expiry date YYYY-MM-DD or RFC3339 (empty = never)")
|
|
keyPath := fs.String("private-key", "", "path to Ed25519 private key (32-byte seed, hex or base64)")
|
|
useDev := fs.Bool("dev", false, "sign with the embedded development key (testing only)")
|
|
out := fs.String("out", "-", "output file path ('-' = stdout)")
|
|
if err := fs.Parse(args); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
if *customer == "" {
|
|
exit("--customer is required")
|
|
}
|
|
if (*keyPath == "") == (!*useDev) {
|
|
exit("exactly one of --private-key or --dev is required")
|
|
}
|
|
|
|
priv, err := loadPrivateKey(*keyPath, *useDev)
|
|
if err != nil {
|
|
exit("load private key: %v", err)
|
|
}
|
|
|
|
id := *licenseID
|
|
if id == "" {
|
|
id = generateLicenseID()
|
|
}
|
|
|
|
c := &license.Claims{
|
|
CustomerID: *customer,
|
|
LicenseID: id,
|
|
IssuedAt: time.Now().UTC().Truncate(time.Second),
|
|
Scope: splitTrim(*scope, ","),
|
|
Features: parseFeatures(*feat),
|
|
Notes: *notes,
|
|
}
|
|
if *expiry != "" {
|
|
t, err := parseDate(*expiry)
|
|
if err != nil {
|
|
exit("--expiry %q: %v", *expiry, err)
|
|
}
|
|
c.Expiry = t
|
|
}
|
|
|
|
tok, err := license.Sign(c, priv)
|
|
if err != nil {
|
|
exit("sign: %v", err)
|
|
}
|
|
encoded := license.EncodeBase64(tok)
|
|
|
|
if *out == "-" {
|
|
fmt.Println(encoded)
|
|
} else {
|
|
if err := os.WriteFile(*out, []byte(encoded+"\n"), 0o600); err != nil {
|
|
exit("write %s: %v", *out, err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "wrote %s\n", *out)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "issued license_id=%s customer=%q scopes=%v expiry=%s\n",
|
|
c.LicenseID, c.CustomerID, c.Scope, formatExpiry(c.Expiry))
|
|
}
|
|
|
|
// inspect --------------------------------------------------------------------
|
|
|
|
func cmdInspect(args []string) {
|
|
fs := flag.NewFlagSet("inspect", flag.ExitOnError)
|
|
if err := fs.Parse(args); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
tok, err := readTokenArg(fs.Args())
|
|
if err != nil {
|
|
exit("%v", err)
|
|
}
|
|
payload, _, err := license.ParseToken(tok)
|
|
if err != nil {
|
|
exit("parse: %v", err)
|
|
}
|
|
c, err := license.DecodeClaims(payload)
|
|
if err != nil {
|
|
exit("decode: %v", err)
|
|
}
|
|
printClaims(c, false)
|
|
}
|
|
|
|
// verify ---------------------------------------------------------------------
|
|
|
|
func cmdVerify(args []string) {
|
|
fs := flag.NewFlagSet("verify", flag.ExitOnError)
|
|
if err := fs.Parse(args); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
tok, err := readTokenArg(fs.Args())
|
|
if err != nil {
|
|
exit("%v", err)
|
|
}
|
|
c, err := license.VerifyToken(tok)
|
|
if err != nil {
|
|
exit("verify: %v", err)
|
|
}
|
|
printClaims(c, true)
|
|
}
|
|
|
|
// keygen ---------------------------------------------------------------------
|
|
|
|
func cmdKeygen(args []string) {
|
|
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
|
|
out := fs.String("out", "", "private key output path (required)")
|
|
pubOut := fs.String("pub-out", "", "public key output path (default: <out>.pub)")
|
|
if err := fs.Parse(args); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
if *out == "" {
|
|
exit("--out is required")
|
|
}
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
exit("generate: %v", err)
|
|
}
|
|
seed := priv.Seed()
|
|
if err := os.WriteFile(*out, []byte(hex.EncodeToString(seed)+"\n"), 0o600); err != nil {
|
|
exit("write %s: %v", *out, err)
|
|
}
|
|
pubPath := *pubOut
|
|
if pubPath == "" {
|
|
pubPath = *out + ".pub"
|
|
}
|
|
if err := os.WriteFile(pubPath, []byte(hex.EncodeToString(pub)+"\n"), 0o644); err != nil {
|
|
exit("write %s: %v", pubPath, err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "wrote private key %s (mode 0600)\n", *out)
|
|
fmt.Fprintf(os.Stderr, "wrote public key %s\n", pubPath)
|
|
fmt.Fprintln(os.Stderr, "Embed the public key in luxfi/license/pubkey.go:IssuerPublicKey before issuing customer tokens.")
|
|
fmt.Println(hex.EncodeToString(pub))
|
|
}
|
|
|
|
// helpers --------------------------------------------------------------------
|
|
|
|
func exit(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func splitTrim(s, sep string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, sep)
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseFeatures(s string) map[string]string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
out := make(map[string]string)
|
|
for _, kv := range splitTrim(s, ",") {
|
|
idx := strings.Index(kv, "=")
|
|
if idx <= 0 {
|
|
exit("--features: %q is not key=value", kv)
|
|
}
|
|
out[strings.TrimSpace(kv[:idx])] = strings.TrimSpace(kv[idx+1:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseDate(s string) (time.Time, error) {
|
|
for _, layout := range []string{"2006-01-02", time.RFC3339, time.DateOnly} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
// Date-only inputs land at start of day UTC; promote to end of day to
|
|
// avoid "issued today, expires today, no usable hours" surprise.
|
|
if layout == "2006-01-02" || layout == time.DateOnly {
|
|
t = t.Add(24*time.Hour - time.Second)
|
|
}
|
|
return t, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("expected YYYY-MM-DD or RFC3339")
|
|
}
|
|
|
|
func generateLicenseID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
exit("rand: %v", err)
|
|
}
|
|
return "lic_" + hex.EncodeToString(b)
|
|
}
|
|
|
|
func loadPrivateKey(path string, dev bool) (ed25519.PrivateKey, error) {
|
|
if dev {
|
|
return license.DevPrivateKey(), nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := strings.TrimSpace(string(data))
|
|
// Accept hex or base64; in either form must decode to a 32-byte seed or a
|
|
// 64-byte expanded private key.
|
|
if seed, err := hex.DecodeString(s); err == nil && (len(seed) == ed25519.SeedSize || len(seed) == ed25519.PrivateKeySize) {
|
|
return seedOrFull(seed)
|
|
}
|
|
if seed, err := base64.StdEncoding.DecodeString(s); err == nil && (len(seed) == ed25519.SeedSize || len(seed) == ed25519.PrivateKeySize) {
|
|
return seedOrFull(seed)
|
|
}
|
|
return nil, errors.New("private key must be hex or base64 encoding of a 32-byte seed (or 64-byte expanded key)")
|
|
}
|
|
|
|
func seedOrFull(b []byte) (ed25519.PrivateKey, error) {
|
|
switch len(b) {
|
|
case ed25519.SeedSize:
|
|
return ed25519.NewKeyFromSeed(b), nil
|
|
case ed25519.PrivateKeySize:
|
|
return ed25519.PrivateKey(b), nil
|
|
default:
|
|
return nil, fmt.Errorf("invalid key length %d", len(b))
|
|
}
|
|
}
|
|
|
|
func readTokenArg(args []string) ([]byte, error) {
|
|
var raw string
|
|
switch {
|
|
case len(args) == 0 || args[0] == "-":
|
|
b, err := io.ReadAll(bufio.NewReader(os.Stdin))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read stdin: %w", err)
|
|
}
|
|
raw = string(b)
|
|
case len(args) == 1:
|
|
// Treat as a file path if it exists; otherwise a literal base64 token.
|
|
if data, err := os.ReadFile(args[0]); err == nil {
|
|
raw = string(data)
|
|
} else {
|
|
raw = args[0]
|
|
}
|
|
default:
|
|
return nil, errors.New("expected a single token argument or '-' for stdin")
|
|
}
|
|
tok, err := license.DecodeBase64(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode base64: %w", err)
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
func printClaims(c *license.Claims, verified bool) {
|
|
fmt.Printf("Customer ID: %s\n", c.CustomerID)
|
|
fmt.Printf("License ID: %s\n", c.LicenseID)
|
|
fmt.Printf("Issued At: %s\n", c.IssuedAt.Format(time.RFC3339))
|
|
fmt.Printf("Expiry: %s\n", formatExpiry(c.Expiry))
|
|
if len(c.Scope) > 0 {
|
|
fmt.Printf("Scopes: %s\n", strings.Join(c.Scope, ", "))
|
|
}
|
|
if len(c.Features) > 0 {
|
|
fmt.Printf("Features:\n")
|
|
for k, v := range c.Features {
|
|
fmt.Printf(" %s = %s\n", k, v)
|
|
}
|
|
}
|
|
if c.Notes != "" {
|
|
fmt.Printf("Notes: %s\n", c.Notes)
|
|
}
|
|
if verified {
|
|
fmt.Println("Status: OK (signature valid, not expired)")
|
|
}
|
|
}
|
|
|
|
func formatExpiry(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|