From 6d3bf819a91ed80c1b04ed3ba9d686111a180138 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Wed, 20 May 2026 21:47:36 +0200 Subject: [PATCH] chore: initial commit CLI to mint RS256 customer JWTs for the YAMA License Server. Thin shell over licensing.Issue() in the YAMA Go server; consumed via local replace directive during development. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 26 +++++++++++ README.md | 120 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 17 +++++++ go.sum | 8 ++++ main.go | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a2fbdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Build artifacts +*.exe +*.test +*.out +yama-issue-token +yama-issue-token-* + +# Secrets — never commit private keys or issued tokens +*.pem +*.key +*.jwt +license_priv* +license_pub* +token-*.jwt + +# Go +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc66234 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# yama-issue-token + +CLI to mint customer JWTs (RS256) for the YAMA Go server's **License Server** +(`/license/sign`, `/license/heartbeat`). + +A thin shell around [`licensing.Issue()`][issue] in the YAMA Go server — +all validation (tier rules, TTL floor, `sub` required) lives there, so +this tool stays in sync as the licensing package evolves. + +[issue]: https://github.com/yuanyuanxiang/SimpleRemoter/blob/main/server/go/licensing/server.go + +## Status + +Personal hobby project, MIT license. Not a commercial product, no SLA. + +## Build + +This module uses a local `replace` directive to consume the YAMA `licensing` +package from a sibling checkout — `go install` from a fresh clone will fail. +Build it locally: + +```bash +git clone https://github.com/yuanyuanxiang/SimpleRemoter.git ../SimpleRemoter +# yama-issue-token expects YAMA at ../YAMA/server/go (see go.mod replace); +# if you cloned as SimpleRemoter, either rename or adjust the replace line. + +git clone https://github.com/yuanyuanxiang/yama-issue-token.git +cd yama-issue-token +go build -o yama-issue-token . +``` + +`go.mod` currently pins: + +```go +replace github.com/yuanyuanxiang/SimpleRemoter/server/go => ../YAMA/server/go +``` + +This is kept on purpose during local development — the tool tracks the +in-tree `licensing` package. A pinned-version setup may come later. + +## One-time RSA keypair + +The License Server verifies issued JWTs with the public key. Generate once, +keep the **private key on the issuer machine only**: + +```bash +openssl genrsa -out license_priv.pem 2048 +openssl rsa -in license_priv.pem -pubout -out license_pub.pem +``` + +Hand `license_pub.pem` to the License Server (`YAMA_LICENSE_PUBLIC_KEY`). +The private key stays with whoever is issuing tokens. + +## Usage + +```text +yama-issue-token -priv -sub [options] + +Required: + -priv RSA private key PEM (PKCS#1 or PKCS#8). + -sub Customer ID — unique string; lands in the JWT "sub" claim. + +Options: + -tier trial | paid (default: trial) + -max Max concurrent devices (trial default: 20; + paid: REQUIRED, no default) + -days Token TTL in days (default: 365; minimum: 1 hour) + -out Write token to this file (0600) instead of stdout +``` + +The signed JWT goes to **stdout** (single line, no trailing whitespace). +Issuance details (sub / tier / max / expires) go to **stderr** and remain +visible when stdout is redirected. + +### Examples + +```bash +# Trial customer, 5 devices, 30 days, print to terminal +yama-issue-token -priv license_priv.pem -sub acme-trial -tier trial -max 5 -days 30 + +# Paid customer, 200 devices, 1 year, write to file (mode 0600) +yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 200 -out token-acme.jwt + +# Pipe into env var (Linux/macOS shell) +export YAMA_LICENSE_TOKEN=$(yama-issue-token -priv license_priv.pem -sub acme -tier paid -max 100) +``` + +## Tier semantics + +| Tier | `-max` default | Notes | +| ------- | -------------- | ----------------------------------------------- | +| `trial` | 20 | Inherits the C++ anti-proxy RTT logic on server | +| `paid` | required | No default — must be supplied explicitly | + +## Integration + +Set on the customer's Go server (RemoteSigner mode): + +```bash +YAMA_LICENSE_SERVER=https://license.example.com +YAMA_LICENSE_TOKEN= +``` + +The customer's server never sees the master HMAC key — it HTTPS-POSTs to +`/license/sign` for each new device login, then caches the signature for +24h (`YAMA_LICENSE_OFFLINE_HRS`). + +See the YAMA repo for the License Server side: +[`server/go/licensing/`](https://github.com/yuanyuanxiang/SimpleRemoter/tree/main/server/go/licensing). + +## Security + +- Private key has no business leaving the issuer host. `.gitignore` excludes + `*.pem`, `*.key`, `*.jwt`, `token-*.jwt`, `license_priv*`, `license_pub*`. +- `-out` writes with mode `0600`. +- JWT `alg` is locked to `RS256` server-side — `alg:none` attacks are rejected. + +## License + +MIT diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6e981d9 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/yuanyuanxiang/yama-issue-token + +go 1.25.0 + +require github.com/yuanyuanxiang/SimpleRemoter/server/go v0.0.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/klauspost/compress v1.18.2 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.32.0 // indirect +) + +// Resolve the YAMA module from the sibling checkout. Keeps this tool a +// thin shell over licensing.Issue() — when the licensing package's Issue +// validation changes, this tool inherits it without code churn. +replace github.com/yuanyuanxiang/SimpleRemoter/server/go => ../YAMA/server/go diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e5b5c8c --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..05405a0 --- /dev/null +++ b/main.go @@ -0,0 +1,134 @@ +// yama-issue-token mints customer JWTs (RS256) for the YAMA License Server. +// +// The tool is a thin shell around licensing.Issue() — all validation +// (tier rules, ttl floor, sub required) is enforced there, so this binary +// stays in sync as the licensing package evolves. +package main + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "flag" + "fmt" + "os" + "time" + + "github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing" +) + +const usage = `yama-issue-token — sign a customer JWT for the YAMA License Server. + +Usage: + yama-issue-token -priv -sub [options] + +Required: + -priv RSA private key PEM (PKCS#1 or PKCS#8). Paired with the public + key the License Server loads via YAMA_LICENSE_PUBLIC_KEY. + -sub Customer ID — unique string, lands in the JWT "sub" claim. + Used by the License Server to track per-customer quotas. + +Options: + -tier trial | paid (default: trial) + -max Max concurrent devices (trial default: 20; + paid: REQUIRED, no default) + -days Token TTL in days (default: 365; minimum: 1 hour) + -out Write token to this file (0600) instead of stdout + +Output: + The signed JWT is printed to stdout (single line, no trailing whitespace), + so you can pipe it cleanly. Issuance details (sub/tier/max/expires) go to + stderr and are visible even when stdout is redirected. + +Examples: + # Trial customer, 5 devices, 30 days, print to terminal + yama-issue-token -priv license_priv.pem -sub acme-trial -tier trial -max 5 -days 30 + + # Paid customer, 200 devices, 1 year, write to file + yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 200 -out token-acme.jwt + + # Pipe into env var (Linux/macOS shell) + export YAMA_LICENSE_TOKEN=$(yama-issue-token -priv ... -sub ... -tier paid -max 100) +` + +func main() { + var ( + priv = flag.String("priv", "", "") + sub = flag.String("sub", "", "") + tier = flag.String("tier", "trial", "") + max = flag.Int("max", 0, "") + days = flag.Int("days", 365, "") + out = flag.String("out", "", "") + ) + flag.Usage = func() { fmt.Fprint(os.Stderr, usage) } + flag.Parse() + + if *priv == "" || *sub == "" { + flag.Usage() + os.Exit(2) + } + if *days <= 0 { + fail("error: -days must be > 0 (got %d)\n", *days) + } + + key, err := loadRSAPrivateKey(*priv) + if err != nil { + fail("error: %v\n", err) + } + + ttl := time.Duration(*days) * 24 * time.Hour + tok, err := licensing.Issue(key, *sub, *tier, *max, ttl) + if err != nil { + fail("error: %v\n", err) + } + + // Resolve the effective max (licensing.Issue defaults trial→20 silently). + effectiveMax := *max + if effectiveMax == 0 && *tier == licensing.TierTrial { + effectiveMax = licensing.TrialMaxDevices + } + fmt.Fprintf(os.Stderr, + "issued: sub=%s tier=%s max_devices=%d ttl=%dd expires=%s\n", + *sub, *tier, effectiveMax, *days, + time.Now().Add(ttl).UTC().Format(time.RFC3339)) + + if *out != "" { + if err := os.WriteFile(*out, []byte(tok+"\n"), 0o600); err != nil { + fail("error writing %s: %v\n", *out, err) + } + fmt.Fprintf(os.Stderr, "wrote: %s (0600)\n", *out) + return + } + fmt.Println(tok) +} + +// loadRSAPrivateKey accepts both `openssl genrsa` output (PKCS#1, header +// `-----BEGIN RSA PRIVATE KEY-----`) and `openssl genpkey -algorithm RSA` +// output (PKCS#8, header `-----BEGIN PRIVATE KEY-----`). +func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM block found in %s", path) + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + if pk, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + rsaKey, ok := pk.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("PKCS#8 key is not RSA") + } + return rsaKey, nil + } + return nil, fmt.Errorf("failed to parse %s as RSA private key (tried PKCS#1 and PKCS#8)", path) +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, format, args...) + os.Exit(1) +}