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) <noreply@anthropic.com>
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -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
|
||||||
120
README.md
Normal file
120
README.md
Normal file
@@ -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 <license_priv.pem> -sub <customer-id> [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 JWT this tool produced>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -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
|
||||||
8
go.sum
Normal file
8
go.sum
Normal file
@@ -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=
|
||||||
134
main.go
Normal file
134
main.go
Normal file
@@ -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 <license_priv.pem> -sub <customer-id> [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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user