Feat(go): add Signer interface + License Server for multi-customer deployments

This commit is contained in:
yuanyuanxiang
2026-05-20 15:11:32 +02:00
parent e264e092f6
commit d808462fe1
14 changed files with 1798 additions and 29 deletions

1
.gitignore vendored
View File

@@ -92,3 +92,4 @@ Bin/*
nul
server/go/web/assets/index.html
server/go/users.json
server/go/build/

View File

@@ -37,6 +37,16 @@ server/go/
│ └── assets/
│ ├── index.html # 从 ../../web/index.html sync 而来 (gitignored)
│ └── static/ # 第三方 xterm.js 资源 (checked in)
├── licensing/
│ ├── signer.go # Signer interface (Sign / Mode / Close)
│ ├── local.go # LocalSigner — operator 部署HMAC 直连
│ ├── remote.go # RemoteSigner — 客户部署HTTPS + singleflight + 24h cache + heartbeat ticker
│ ├── noop.go # NoOpSigner — free tier返回空签名
│ ├── token.go # JWT RS256 校验 + RSA 公钥加载
│ ├── quota.go # 配额追踪 (Reserve / RefreshExisting / 5min eviction)
│ ├── server.go # License Server HTTP handlers (/license/sign, /license/heartbeat) + Issue()
│ ├── factory.go # NewFromEnv / LicenseServerFromEnv + 模式选择
│ └── licensing_test.go # 17 个单元测试
└── cmd/
└── main.go # 程序入口
```
@@ -148,7 +158,12 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
| `YAMA_PWDHASH` | 密码的 SHA256 哈希值 (64位十六进制) | `61f04dd6...` |
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
| `YAMA_SIGN_PASSWORD` | HMAC-SHA256 key used to sign CMD_MASTERSETTING replies; must match the client's expected value. Provision out-of-band. Unset → client refuses screen/file ops. | `<deployment-shared-secret>` |
| `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` |
| `YAMA_LICENSE_SERVER` | **[RemoteSigner 模式]** Operator 的 License Server 公开 URL。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。必须与 `YAMA_LICENSE_TOKEN` 同时设置。 | `https://license.example.com` |
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` |
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
| `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` |
| `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**RemoteSigner 客户不能反向当 license server。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` |
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
@@ -165,6 +180,46 @@ $env:YAMA_PWD="your_super_password"
.\simpleremoter-server.exe
```
## 签名模式CMD_MASTERSETTING signer
单个 Go 二进制按启动时的环境变量自动选择三种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。
| 模式 | 触发条件 | 用途 |
| ---- | -------- | ---- |
| **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 |
| **RemoteSigner** | `YAMA_LICENSE_SERVER` + `YAMA_LICENSE_TOKEN` 已设 | 客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h可调`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 |
| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
注:`YAMA_SIGN_PASSWORD``YAMA_LICENSE_SERVER` 同时设置时 LocalSigner 优先operator 自己的 server 不应该回连自己)。
### License Server endpoints仅 LocalSigner 暴露)
设了 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 后,本进程会监听 `YAMA_LICENSE_HTTP_ADDR` 提供两个端点:
- `POST /license/sign` — body `{"client_id":"...","start_time":"..."}`header `Authorization: Bearer <customer-JWT>`,回 `{"signature":"<64-hex>"}`。强制按 JWT 的 `tier` + `max_devices` 配额限制。
- `POST /license/heartbeat` — body `{"active_device_count":N,"active_device_ids":["..."]}`,回 `{"server_view_count":M,"drift":N-M}`。Drift 大于阈值时记日志,供 operator 反作弊审核。
部署建议:本进程只跑 plain HTTP前面挂 nginx/Caddy/cloudflare 加 TLS。JWT 校验已经把 `alg` 锁死成 RS256杜绝 `alg:none` 攻击。
### 颁发客户 JWT
```bash
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
openssl genrsa -out license_priv.pem 2048
openssl rsa -in license_priv.pem -pubout -out license_pub.pem
```
底层 API 是 `licensing.Issue(privKey, sub, tier, maxDevices, ttl)`(见 [`licensing/server.go`](licensing/server.go))。一个开箱即用的 CLI 包装在独立仓库 [`yama-issue-token`](https://github.com/yuanyuanxiang/yama-issue-token)go.mod `replace` 指向本仓库的 `licensing` 包),用法:
```bash
yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 100 -days 365
```
| Tier | max_devices 默认 | 备注 |
| ---- | ---------------- | ---- |
| `trial` | 20JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 |
| `paid` | JWT 必须显式指定 | 长 TTL token |
## 使用示例
完整的 TCP + Hub + Web 集成示例就是 [`cmd/main.go`](cmd/main.go),那是程序入口本身、也是最权威的范例 —— 包含 handler 装配、hub 注册、web HTTP/WS 服务、信号优雅关闭等。

View File

@@ -1,9 +1,11 @@
package main
import (
"context"
"encoding/binary"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
@@ -14,6 +16,7 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
@@ -23,11 +26,11 @@ import (
// MyHandler implements the server.Handler interface
type MyHandler struct {
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signPwd string // HMAC key for CMD_MASTERSETTING signatures (YAMA_SIGN_PASSWORD)
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
}
// OnConnect is called when a client connects
@@ -436,10 +439,14 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
// down the main TCP connection. Most fields stay zeroed — only Signature
// matters today. If no signing password is configured, a zeroed signature is
// still sent (and logged once) so the client at least sees a well-formed
// packet; in that case the client's private library will refuse to start
// screen / file features and abort.
// matters today. The signer is one of:
// - LocalSigner: HMAC directly with master key (operator's own deployment)
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
//
// On signer error (License Server unreachable + no cache hit), we still ship
// a zeroed signature so the packet is well-formed; the client will retry on
// next reconnect.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
buf := make([]byte, 1+protocol.MasterSettingsSize)
buf[0] = protocol.CmdMasterSetting
@@ -451,12 +458,14 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
buf[1:5],
uint32(protocol.DefaultReportIntervalSec))
if h.signPwd == "" {
h.log.Warn("YAMA_SIGN_PASSWORD not set — client may abort on screen/file ops")
sig, err := h.signer.Sign(startTime, clientID)
if err != nil {
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
h.signer.Mode(), clientID, err)
} else if sig == "" {
// NoOpSigner path, or LocalSigner with empty master key — same effect.
// Log only once per process via the startup banner; don't spam here.
} else {
msg := startTime + "|" + clientID
sig := protocol.SignMessage(h.signPwd, []byte(msg))
// Signature[64] lives at offset 508 of the struct, +1 for the cmd byte.
const sigOffset = 1 + protocol.MasterSettingsOffSignature
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
}
@@ -676,14 +685,37 @@ func main() {
// the HTTP server reads from it.
deviceHub := hub.New()
// HMAC key used to sign the per-login CMD_MASTERSETTING reply. The
// client verifies this signature before enabling its screen / file
// features and aborts the process on mismatch. Kept in an env var so
// the literal stays out of the binary; provision out-of-band and
// never commit it.
signPwd := os.Getenv("YAMA_SIGN_PASSWORD")
if signPwd == "" {
log.Warn("YAMA_SIGN_PASSWORD not set; clients will refuse screen/file ops")
// Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here)
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
// (customer deployment; never sees the master key, fetches signatures
// from operator's License Server with 24h cache)
// - neither → NoOpSigner (free tier; client refuses screen/file ops
// but device list still works)
signer, mode, err := licensing.NewFromEnv(log)
if err != nil {
log.Fatal("Failed to initialize signer: %v", err)
}
// signer.Close() is called explicitly after the License Server HTTP is
// drained at shutdown — sequencing matters because an in-flight
// handleSign on the HTTP path needs a live signer to complete.
switch mode {
case licensing.ModeLocal:
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
case licensing.ModeRemote:
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)",
licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer))
case licensing.ModeNoOp:
log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)")
}
// If the operator also wants this LocalSigner deployment to serve as
// License Server for RemoteSigner customers, YAMA_LICENSE_PUBLIC_KEY +
// YAMA_LICENSE_HTTP_ADDR enable it. Off by default.
licSrv, licAddr, err := licensing.LicenseServerFromEnv(signer, log)
if err != nil {
log.Fatal("Failed to initialize License Server: %v", err)
}
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
@@ -726,11 +758,11 @@ func main() {
// Create handler for this server
handler := &MyHandler{
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signPwd: signPwd,
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signer: signer,
}
srv.SetHandler(handler)
@@ -785,10 +817,42 @@ func main() {
log.Fatal("Failed to start HTTP server: %v", err)
}
// Optionally serve the License Server HTTP endpoints in this process.
// Only active when the operator set YAMA_LICENSE_PUBLIC_KEY +
// YAMA_LICENSE_HTTP_ADDR. We serve plain HTTP — operator should put
// nginx/Caddy in front for TLS. (RemoteSigner customers connect to
// the public URL configured via YAMA_LICENSE_SERVER on their side.)
//
// Timeouts cap Slowloris / FD-exhaustion attacks. Values are generous
// enough for a slow public link (TLS-terminating proxy in front, real
// customer round-trips at ~hundreds of ms) but tight enough that a
// trickle-byte attacker cannot pin a connection indefinitely.
var licenseHTTP *http.Server
if licSrv != nil {
licenseHTTP = &http.Server{
Addr: licAddr,
Handler: licSrv.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
go func() {
log.Info("License Server listening on %s (POST /license/sign, /license/heartbeat)", licAddr)
if err := licenseHTTP.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("License Server stopped: %v", err)
}
}()
}
fmt.Printf("Server started on port(s): %v\n", ports)
if *httpPort != 0 {
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
}
if licenseHTTP != nil {
fmt.Printf("License Server on http://%s/license/{sign,heartbeat}\n", licAddr)
}
fmt.Println("Logs are written to: logs/server.log")
fmt.Println("Press Ctrl+C to stop...")
@@ -798,6 +862,17 @@ func main() {
<-sigChan
fmt.Println("\nShutting down...")
// Order matters: drain License Server HTTP first so no handleSign is
// mid-flight; THEN close the signer (which may release HTTP keepalives
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
if licenseHTTP != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = licenseHTTP.Shutdown(shutdownCtx)
cancel()
}
if err := signer.Close(); err != nil {
log.Warn("signer Close: %v", err)
}
httpSrv.Stop()
for _, srv := range servers {
srv.Stop()

View File

@@ -1,8 +1,9 @@
module github.com/yuanyuanxiang/SimpleRemoter/server/go
go 1.24.5
go 1.25.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.18.2
github.com/rs/zerolog v1.34.0
@@ -13,5 +14,6 @@ require (
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.12.0 // indirect
)

View File

@@ -1,5 +1,7 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
@@ -13,6 +15,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
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/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=

View File

@@ -0,0 +1,154 @@
package licensing
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Env var names — keep these in one place so README and code stay in sync.
const (
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
)
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
// project memory's licensing design.
const DefaultOfflineGrace = 24 * time.Hour
// Mode is what NewFromEnv inspected to pick a Signer; useful for logging.
type Mode int
const (
ModeLocal Mode = iota
ModeRemote
ModeNoOp
)
func (m Mode) String() string {
switch m {
case ModeLocal:
return "local"
case ModeRemote:
return "remote"
default:
return "noop"
}
}
// SelectedMode reports which mode NewFromEnv picked based on env vars,
// without actually constructing a signer. Useful for startup banners.
func SelectedMode() Mode {
if os.Getenv(EnvSignPassword) != "" {
return ModeLocal
}
if os.Getenv(EnvLicenseServer) != "" && os.Getenv(EnvLicenseToken) != "" {
return ModeRemote
}
return ModeNoOp
}
// NewFromEnv builds the Signer chosen by env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
// - neither → NoOpSigner
//
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
// (an operator's own server should never accidentally call out to itself).
// In that case we log a loud warning so the operator notices the dead config.
//
// <lg> may be nil — RemoteSigner falls back to a silent logger internally.
func NewFromEnv(lg Logger) (Signer, Mode, error) {
server := os.Getenv(EnvLicenseServer)
token := os.Getenv(EnvLicenseToken)
pwd := os.Getenv(EnvSignPassword)
if pwd != "" {
if (server != "" || token != "") && lg != nil {
lg.Warn("%s is set; ignoring %s/%s (LocalSigner wins) — "+
"if you meant to be a customer deployment, unset %s",
EnvSignPassword, EnvLicenseServer, EnvLicenseToken, EnvSignPassword)
}
s, err := NewLocal(pwd)
if err != nil {
return nil, ModeNoOp, err
}
return s, ModeLocal, nil
}
if server != "" && token != "" {
if err := ValidateRemoteURL(server); err != nil {
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
}
grace := DefaultOfflineGrace
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
n, err := strconv.Atoi(strings.TrimSpace(hrs))
if err != nil {
return nil, ModeNoOp, fmt.Errorf(
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
}
if n < 0 {
return nil, ModeNoOp, fmt.Errorf(
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
}
grace = time.Duration(n) * time.Hour
}
return NewRemote(server, token, grace, lg), ModeRemote, nil
}
if server != "" || token != "" {
// Partial config is almost certainly a misconfiguration — fail loudly
// rather than silently degrading to NoOp.
return nil, ModeNoOp, fmt.Errorf(
"%s and %s must be set together (got %s=%q %s=%q)",
EnvLicenseServer, EnvLicenseToken,
EnvLicenseServer, server, EnvLicenseToken, token)
}
return NewNoOp(), ModeNoOp, nil
}
// LicenseServerFromEnv builds the License Server HTTP handler if (and only
// if) the operator wants to expose it: requires both YAMA_LICENSE_PUBLIC_KEY
// (to verify customer JWTs) and YAMA_LICENSE_HTTP_ADDR (listen address) set,
// AND the Signer must be a *LocalSigner (RemoteSigner customer deployments
// must never serve a License Server — they don't have the master key).
//
// Returns (nil, "", nil) if not configured. Returns (server, addr, nil) when
// configured. Returns error on bad / partial config.
func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, error) {
pubPath := os.Getenv(EnvLicensePubKeyPath)
addr := os.Getenv(EnvLicenseHTTPAddr)
if pubPath == "" && addr == "" {
return nil, "", nil // not configured — fine
}
if pubPath == "" || addr == "" {
return nil, "", fmt.Errorf(
"%s and %s must be set together to enable License Server (got %s=%q %s=%q)",
EnvLicensePubKeyPath, EnvLicenseHTTPAddr,
EnvLicensePubKeyPath, pubPath, EnvLicenseHTTPAddr, addr)
}
local, ok := signer.(*LocalSigner)
if !ok {
return nil, "", fmt.Errorf(
"License Server requires LocalSigner mode (set %s); current mode is %s",
EnvSignPassword, signer.Mode())
}
pubKey, err := LoadRSAPublicKey(pubPath)
if err != nil {
return nil, "", err
}
// 5-minute eviction window — twice a typical heartbeat interval. Matches
// the discussion in quota.go.
return NewLicenseServer(local, pubKey, 5*time.Minute, lg), addr, nil
}

View File

@@ -0,0 +1,520 @@
package licensing
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// testKey generates an ephemeral 2048-bit RSA key for JWT signing in tests.
// 2048 keeps the test fast (~50ms) but matches realistic security.
func testKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
return k
}
// silentLogger swallows logs in tests but still satisfies the Logger
// interface — keeps test output uncluttered.
type silentLogger struct{}
func (silentLogger) Info(string, ...any) {}
func (silentLogger) Warn(string, ...any) {}
func (silentLogger) Error(string, ...any) {}
// mustLocal wraps NewLocal for tests so the per-test boilerplate stays
// readable. Any test-only HMAC key must be >= 16 chars (see minMasterKeyLen).
func mustLocal(t *testing.T, key string) *LocalSigner {
t.Helper()
s, err := NewLocal(key)
if err != nil {
t.Fatalf("NewLocal: %v", err)
}
return s
}
// TestIssueVerifyRoundTrip is the smoke test: a token minted by Issue
// must verify under VerifyJWT with the matching public key, and the
// claims must round-trip intact.
func TestIssueVerifyRoundTrip(t *testing.T) {
priv := testKey(t)
tok, err := Issue(priv, "customer-abc", TierTrial, 0, 30*24*time.Hour)
if err != nil {
t.Fatalf("Issue: %v", err)
}
claims, err := VerifyJWT(tok, &priv.PublicKey)
if err != nil {
t.Fatalf("VerifyJWT: %v", err)
}
if claims.Subject != "customer-abc" {
t.Errorf("sub = %q, want customer-abc", claims.Subject)
}
if claims.Tier != TierTrial {
t.Errorf("tier = %q, want %q", claims.Tier, TierTrial)
}
if claims.MaxDevices != TrialMaxDevices {
// Trial JWT minted with MaxDevices=0 should default to TrialMaxDevices
// (VerifyJWT normalizes this — see token.go).
t.Errorf("max_devices = %d, want %d (trial default)", claims.MaxDevices, TrialMaxDevices)
}
}
// TestVerifyRejectsWrongKey makes sure a token signed by key A cannot
// be verified with key B's public half. This is what would fail open
// if "alg":"none" tampering wasn't blocked, or if someone reused keys.
func TestVerifyRejectsWrongKey(t *testing.T) {
priv1 := testKey(t)
priv2 := testKey(t)
tok, err := Issue(priv1, "customer-x", TierPaid, 50, time.Hour)
if err != nil {
t.Fatalf("Issue: %v", err)
}
if _, err := VerifyJWT(tok, &priv2.PublicKey); err == nil {
t.Fatal("VerifyJWT accepted token signed with a different key")
}
}
// TestPaidRequiresMaxDevices: paid tier must carry an explicit cap; trial
// gets a default. Catches misconfigured tokens at verify time.
func TestPaidRequiresMaxDevices(t *testing.T) {
priv := testKey(t)
_, err := Issue(priv, "customer-y", TierPaid, 0, time.Hour)
if err == nil {
t.Fatal("Issue accepted paid tier with max_devices=0")
}
}
// TestNoOpSignerReturnsEmpty: free tier produces no signature so the
// client's private library trips and refuses high-tier features.
func TestNoOpSignerReturnsEmpty(t *testing.T) {
s := NewNoOp()
sig, err := s.Sign("2026-05-20", "12345")
if err != nil {
t.Fatalf("Sign: %v", err)
}
if sig != "" {
t.Errorf("NoOpSigner.Sign = %q, want empty", sig)
}
if s.Mode() != "noop" {
t.Errorf("Mode = %q, want noop", s.Mode())
}
}
// TestLocalSignerDeterministic: HMAC is deterministic — same key + same
// input must always yield the same output. This is the property that
// makes RemoteSigner's cache correct.
func TestLocalSignerDeterministic(t *testing.T) {
s, err := NewLocal("shared-secret-xyz-long-enough")
if err != nil {
t.Fatalf("NewLocal: %v", err)
}
a, _ := s.Sign("2026-05-20T10:00:00Z", "12345")
b, _ := s.Sign("2026-05-20T10:00:00Z", "12345")
if a != b {
t.Errorf("non-deterministic: %q vs %q", a, b)
}
if len(a) != 64 {
t.Errorf("signature length = %d, want 64 (hex of HMAC-SHA256)", len(a))
}
}
// TestRemoteSignerCacheHit verifies that the second call for the same
// (startTime, clientID) tuple doesn't hit the network. We assert this
// by counting requests at the fake License Server.
func TestRemoteSignerCacheHit(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "real-hmac-key-for-test-xx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
tok, err := Issue(priv, "cust-cache", TierPaid, 10, time.Hour)
if err != nil {
t.Fatalf("Issue: %v", err)
}
// Count requests by wrapping the LS handler.
var calls atomic.Int64
counting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
ls.Handler().ServeHTTP(w, r)
}))
defer counting.Close()
rs := NewRemote(counting.URL, tok, time.Hour, silentLogger{})
defer rs.Close()
sig1, err := rs.Sign("st-1", "client-1")
if err != nil {
t.Fatalf("first Sign: %v", err)
}
sig2, err := rs.Sign("st-1", "client-1") // identical → cache hit
if err != nil {
t.Fatalf("second Sign: %v", err)
}
if sig1 != sig2 {
t.Errorf("signatures differ across cache: %q vs %q", sig1, sig2)
}
if got := calls.Load(); got != 1 {
t.Errorf("expected exactly 1 HTTP call (second served from cache), got %d", got)
}
}
// TestRemoteSignerStaleFallback: when the License Server goes down, an
// expired-cache entry is still better than zero signature (avoids breaking
// reconnects for existing devices during a transient outage).
func TestRemoteSignerStaleFallback(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-fallback-test-xxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
ts := httptest.NewServer(ls.Handler())
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
if err != nil {
t.Fatalf("Issue: %v", err)
}
// Grace = 1 ns so the next call considers the cache stale.
rs := NewRemote(ts.URL, tok, time.Nanosecond, silentLogger{})
defer rs.Close()
first, err := rs.Sign("st-fb", "client-fb")
if err != nil {
t.Fatalf("first Sign: %v", err)
}
// Take the server offline.
ts.Close()
second, err := rs.Sign("st-fb", "client-fb")
if err != nil {
t.Fatalf("post-outage Sign should fall back to stale cache, got err=%v", err)
}
if second != first {
t.Errorf("stale fallback returned %q, want cached %q", second, first)
}
}
// TestQuotaEnforcement: trial tier with 2 devices accepts the first two
// but rejects the third with 403.
func TestQuotaEnforcement(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-quota-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
// Trial JWT capped at 2 devices.
tok, err := Issue(priv, "cust-quota", TierTrial, 2, time.Hour)
if err != nil {
t.Fatalf("Issue: %v", err)
}
call := func(clientID string) (int, string) {
body, _ := json.Marshal(signRequest{ClientID: clientID, StartTime: "st"})
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
var sr signResponse
_ = json.NewDecoder(resp.Body).Decode(&sr)
if sr.Signature != "" {
return resp.StatusCode, sr.Signature
}
return resp.StatusCode, sr.Error
}
if code, _ := call("dev-1"); code != http.StatusOK {
t.Errorf("dev-1 expected 200, got %d", code)
}
if code, _ := call("dev-2"); code != http.StatusOK {
t.Errorf("dev-2 expected 200, got %d", code)
}
if code, msg := call("dev-3"); code != http.StatusForbidden {
t.Errorf("dev-3 expected 403, got %d (%q)", code, msg)
}
}
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt
// and braces — the auth check sits in front of /sign and /heartbeat.
func TestAuthRejectsMissingBearer(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-auth-test-xxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`)
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
if err != nil {
t.Fatalf("Post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
// TestRemoteSignerHardFailNoCacheReturnsError: when the LS is unreachable
// AND we have no cached signature for this (startTime, clientID), Sign must
// return ("", err) so the caller (sendMasterSetting) ships zeroed bytes.
// Previously untested — easy to silently regress.
func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) {
// Point RemoteSigner at a URL that will refuse all connections.
// Using an unreachable host avoids depending on a free local port.
rs := NewRemote("https://127.0.0.1:1", "any-token", time.Hour, silentLogger{})
defer rs.Close()
sig, err := rs.Sign("st-x", "client-x")
if err == nil {
t.Fatal("expected error when LS unreachable and cache empty")
}
if sig != "" {
t.Errorf("expected empty signature on hard failure, got %q", sig)
}
}
// TestHeartbeatRefreshOnly: malicious customer POSTs fake clientIDs to
// /license/heartbeat. The fake IDs MUST NOT show up in the server's view —
// only IDs already minted via /sign get refreshed. This is the anti-tamper
// property that makes the quota system actually enforce.
func TestHeartbeatRefreshOnly(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-hb-test-xxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
tok, err := Issue(priv, "cust-hb", TierPaid, 5, time.Hour)
if err != nil {
t.Fatalf("Issue: %v", err)
}
// Reserve one legit device via /sign first.
signBody, _ := json.Marshal(signRequest{ClientID: "legit-1", StartTime: "st"})
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(signBody)))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
if resp, err := http.DefaultClient.Do(req); err != nil {
t.Fatalf("Do sign: %v", err)
} else {
resp.Body.Close()
}
// Now heartbeat reports 1 legit ID + 99 fake IDs.
fakes := make([]string, 99)
for i := range fakes {
fakes[i] = fmt.Sprintf("fake-%d", i)
}
all := append([]string{"legit-1"}, fakes...)
hbBody, _ := json.Marshal(heartbeatRequest{
ActiveDeviceCount: len(all),
ActiveDeviceIDs: all,
})
req, _ = http.NewRequest("POST", ts.URL+"/license/heartbeat", strings.NewReader(string(hbBody)))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do heartbeat: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("heartbeat returned %d", resp.StatusCode)
}
var hbResp heartbeatResponse
_ = json.NewDecoder(resp.Body).Decode(&hbResp)
// Server should report exactly 1 active device (the one minted via /sign),
// NOT 1 + 99. drift = 99.
if hbResp.ServerViewCount != 1 {
t.Errorf("server view = %d, want 1 (heartbeat must not insert fake IDs)", hbResp.ServerViewCount)
}
if hbResp.Drift != 99 {
t.Errorf("drift = %d, want 99", hbResp.Drift)
}
// Verify the quota cap is still enforced: a fresh /sign for dev-2..dev-5
// should succeed (only 1 slot used), dev-6 should fail.
tryReserve := func(cid string) int {
body, _ := json.Marshal(signRequest{ClientID: cid, StartTime: "st"})
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
return resp.StatusCode
}
for i := 2; i <= 5; i++ {
if code := tryReserve(fmt.Sprintf("legit-%d", i)); code != http.StatusOK {
t.Errorf("legit-%d expected 200, got %d", i, code)
}
}
if code := tryReserve("legit-6"); code != http.StatusForbidden {
t.Errorf("legit-6 expected 403 (max=5), got %d", code)
}
}
// TestQuotaRejectionDoesNotConsumeSlot: a rejected /sign must not leave
// its clientID in the quota map. Otherwise a denied 3rd device would
// permanently take a slot from the legitimate 1st/2nd.
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
tok, err := Issue(priv, "cust-leak", TierTrial, 2, time.Hour)
if err != nil {
t.Fatalf("Issue: %v", err)
}
doSign := func(cid string) int {
body, _ := json.Marshal(signRequest{ClientID: cid, StartTime: "st"})
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
return resp.StatusCode
}
// Fill to cap.
if c := doSign("a"); c != http.StatusOK {
t.Fatalf("a expected 200, got %d", c)
}
if c := doSign("b"); c != http.StatusOK {
t.Fatalf("b expected 200, got %d", c)
}
// Over cap — must be denied AND must NOT consume a slot.
if c := doSign("c"); c != http.StatusForbidden {
t.Fatalf("c expected 403, got %d", c)
}
if c := doSign("d"); c != http.StatusForbidden {
t.Fatalf("d expected 403, got %d", c)
}
// Existing device 'a' re-signs — must still succeed (idempotent refresh).
if c := doSign("a"); c != http.StatusOK {
t.Fatalf("a re-sign expected 200, got %d", c)
}
}
// TestQuotaTrackerEviction: after evictAfter elapses, a previously-occupied
// slot must be reclaimed so a new device can take it. Exercises the time
// path that TestQuotaEnforcement skips (it uses a long eviction window).
func TestQuotaTrackerEviction(t *testing.T) {
q := newQuotaTracker(50 * time.Millisecond)
if count, ok := q.Reserve("cust", "dev-1", 1); !ok || count != 1 {
t.Fatalf("first Reserve: count=%d ok=%v", count, ok)
}
if count, ok := q.Reserve("cust", "dev-2", 1); ok {
t.Fatalf("expected over-cap rejection, got count=%d ok=%v", count, ok)
}
time.Sleep(80 * time.Millisecond)
// dev-1's entry should now be stale; dev-2 should be admitted.
if count, ok := q.Reserve("cust", "dev-2", 1); !ok || count != 1 {
t.Fatalf("post-eviction Reserve: count=%d ok=%v", count, ok)
}
}
// TestValidateRemoteURL: factory must reject http:// for non-loopback
// targets so JWT/sigs don't leak in cleartext.
func TestValidateRemoteURL(t *testing.T) {
cases := []struct {
url string
wantError bool
}{
{"https://license.example.com", false},
{"https://localhost:8443", false},
{"http://localhost:8443", false}, // loopback exception
{"http://127.0.0.1:8443", false}, // loopback exception
{"http://license.example.com", true}, // public http → reject
{"ftp://license.example.com", true}, // bad scheme
{"not a url at all", true},
}
for _, c := range cases {
err := ValidateRemoteURL(c.url)
if (err != nil) != c.wantError {
t.Errorf("ValidateRemoteURL(%q): err=%v, wantError=%v", c.url, err, c.wantError)
}
}
}
// TestIssueRejectsShortTTL: catches fat-finger ttl=0 / negative that mints
// an already-expired token.
func TestIssueRejectsShortTTL(t *testing.T) {
priv := testKey(t)
if _, err := Issue(priv, "cust", TierPaid, 10, 0); err == nil {
t.Error("expected error for ttl=0")
}
if _, err := Issue(priv, "cust", TierPaid, 10, time.Minute); err == nil {
t.Error("expected error for ttl below minimum")
}
if _, err := Issue(priv, "", TierPaid, 10, time.Hour); err == nil {
t.Error("expected error for empty sub")
}
}
// TestNewLocalRejectsShortKey: catches misconfigured YAMA_SIGN_PASSWORD
// (empty / typo) at construction instead of silently signing with junk.
func TestNewLocalRejectsShortKey(t *testing.T) {
if _, err := NewLocal(""); err == nil {
t.Error("expected error for empty master key")
}
if _, err := NewLocal("short"); err == nil {
t.Error("expected error for too-short master key")
}
}
// TestJWTAlgLockedToRS256: a token with any non-RS256 alg (here RS384) must
// fail verification, even though the underlying RSA primitive is the same.
// This pins the docs↔code contract.
func TestJWTAlgLockedToRS256(t *testing.T) {
priv := testKey(t)
now := time.Now()
claims := &LicenseClaims{
Tier: TierTrial,
MaxDevices: 10,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "cust",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS384, claims)
signed, err := tok.SignedString(priv)
if err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := VerifyJWT(signed, &priv.PublicKey); err == nil {
t.Error("VerifyJWT accepted RS384; alg should be locked to RS256")
}
}

View File

@@ -0,0 +1,45 @@
package licensing
import (
"errors"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
)
// LocalSigner signs directly with the deployment's HMAC master key. The
// operator's own Go server runs in this mode; it also serves as License
// Server for any RemoteSigner customer deployments (LicenseServer in
// server.go reuses the same Signer instance via its public Sign() method).
//
// Sign is HMAC-SHA256 in microseconds — no I/O, no caching needed.
type LocalSigner struct {
masterKey string
}
// minMasterKeyLen rejects obviously-broken HMAC keys (empty string, "x").
// Real keys are typically `openssl rand -hex 32` (64 chars); 16 bytes is
// the floor we'll accept to catch fat-finger configs without being too
// strict for tests.
const minMasterKeyLen = 16
// NewLocal returns a LocalSigner. An empty or too-short masterKey is a
// configuration bug — silently accepting it would produce stable but
// worthless HMAC output, so we reject at construction.
func NewLocal(masterKey string) (*LocalSigner, error) {
if masterKey == "" {
return nil, errors.New("LocalSigner: master HMAC key is empty (set YAMA_SIGN_PASSWORD)")
}
if len(masterKey) < minMasterKeyLen {
return nil, errors.New("LocalSigner: master HMAC key too short (need >= 16 chars)")
}
return &LocalSigner{masterKey: masterKey}, nil
}
func (l *LocalSigner) Sign(startTime, clientID string) (string, error) {
msg := startTime + "|" + clientID
return protocol.SignMessage(l.masterKey, []byte(msg)), nil
}
func (l *LocalSigner) Mode() string { return "local" }
func (l *LocalSigner) Close() error { return nil }

View File

@@ -0,0 +1,18 @@
package licensing
// NoOpSigner returns an empty signature. Used when neither YAMA_SIGN_PASSWORD
// nor YAMA_LICENSE_SERVER is set — operator hasn't configured licensing at
// all (free-tier mode). Device list keeps working; the client's private
// library refuses to start screen/file features when it sees a zeroed
// Signature[64] field in CMD_MASTERSETTING.
type NoOpSigner struct{}
func NewNoOp() *NoOpSigner { return &NoOpSigner{} }
func (n *NoOpSigner) Sign(startTime, clientID string) (string, error) {
return "", nil
}
func (n *NoOpSigner) Mode() string { return "noop" }
func (n *NoOpSigner) Close() error { return nil }

View File

@@ -0,0 +1,159 @@
package licensing
import (
"sync"
"time"
)
// Tier names. The free tier is hard-coded into the License Server (no JWT
// needed); trial / paid are encoded in the issued JWT's "tier" claim along
// with "max_devices".
const (
TierFree = "free"
TierTrial = "trial"
TierPaid = "paid"
)
// FreeMaxDevices is the per-deployment device cap for unauthenticated
// (no JWT) callers. Trial defaults to 20 if the JWT omits max_devices;
// paid customers MUST have an explicit max_devices in their JWT.
const (
FreeMaxDevices = 2
TrialMaxDevices = 20
)
// quotaTracker maintains the active-device set per customer. Customers are
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64
// from the device, stringified) — same device coming back through the
// same License Server is one slot, not two.
//
// Eviction: any clientID not seen in /sign or /license/heartbeat within
// the eviction window is silently dropped from the active set. This stops
// a never-heartbeating customer from holding slots forever. Default
// window is twice the heartbeat interval the customer reports at (5 min).
//
// Empty customer entries are reaped at the end of each mutation so the
// outer map doesn't accumulate sub claims of expired contracts.
type quotaTracker struct {
evictAfter time.Duration
mu sync.Mutex
customer map[string]*customerState // sub claim → state
}
type customerState struct {
devices map[string]time.Time // clientID → last activity
}
func newQuotaTracker(evictAfter time.Duration) *quotaTracker {
return &quotaTracker{
evictAfter: evictAfter,
customer: make(map[string]*customerState),
}
}
// evictLocked drops stale entries from st.devices. Caller must hold q.mu.
func (q *quotaTracker) evictLocked(st *customerState) {
cutoff := time.Now().Add(-q.evictAfter)
for cid, last := range st.devices {
if last.Before(cutoff) {
delete(st.devices, cid)
}
}
}
// reapEmptyLocked deletes sub entries whose device sets are empty. This
// prevents long-running License Servers from leaking memory across
// churned trial customers. Caller must hold q.mu.
func (q *quotaTracker) reapEmptyLocked(sub string) {
if st, ok := q.customer[sub]; ok && len(st.devices) == 0 {
delete(q.customer, sub)
}
}
// Reserve atomically checks whether <clientID> can be admitted under <sub>
// without exceeding <maxDevices>, and if so records it as active. Returns
// (active_count_after, accepted).
//
// "Accepted" means the slot is now reserved. "Rejected" leaves the device
// map untouched — a denied request must NOT permanently consume a slot
// just because the caller asked.
//
// If <clientID> is already in the set, Reserve refreshes its activity
// timestamp and returns accepted=true regardless of maxDevices (same device
// re-signing is never a quota violation — caps only apply to ADDING).
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub]
if !ok {
st = &customerState{devices: make(map[string]time.Time)}
q.customer[sub] = st
}
q.evictLocked(st)
if _, already := st.devices[clientID]; already {
st.devices[clientID] = time.Now()
return len(st.devices), true
}
if len(st.devices)+1 > maxDevices {
// Don't reap on rejection — the customer might be at exactly cap
// with valid devices, and an empty map would lose info.
return len(st.devices), false
}
st.devices[clientID] = time.Now()
return len(st.devices), true
}
// RefreshExisting bumps the last-activity timestamp for any clientID in
// <clientIDs> that is ALREADY in <sub>'s set. New IDs are silently ignored
// — this is the key anti-tamper property of /license/heartbeat: a
// malicious customer cannot inflate their quota by reporting fake IDs.
//
// Returns the count of IDs that were actually refreshed (i.e. that were
// known to us from a prior Reserve).
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub]
if !ok {
return 0
}
q.evictLocked(st)
now := time.Now()
refreshed := 0
for _, cid := range clientIDs {
if _, exists := st.devices[cid]; exists {
st.devices[cid] = now
refreshed++
}
}
q.reapEmptyLocked(sub) // eviction may have emptied us
return refreshed
}
// Snapshot returns the current active clientIDs for <sub>. Used by
// /license/heartbeat to report the server-side view.
func (q *quotaTracker) Snapshot(sub string) []string {
q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub]
if !ok {
return nil
}
q.evictLocked(st)
out := make([]string, 0, len(st.devices))
for cid := range st.devices {
out = append(out, cid)
}
q.reapEmptyLocked(sub)
return out
}

View File

@@ -0,0 +1,295 @@
package licensing
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// RemoteSigner fetches per-login signatures from an operator-hosted License
// Server. ServerURL and Token (a JWT issued offline by the operator) are
// loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup.
//
// Cache strategy: every (startTime, clientID) tuple deterministically
// produces the same HMAC output. So once we've fetched a signature, we
// can serve it from memory for OfflineGrace (default 24h). We do honor the
// grace — the operator may want to revoke license / clear cache during
// outages — returning a stale signature beyond OfflineGrace defeats the
// point of the License Server.
//
// Thundering herd: on cache miss, concurrent Sign() calls for the same
// (startTime, clientID) are coalesced via singleflight so only one HTTPS
// round-trip happens. This matters at customer-side restart when many
// devices reconnect at the same time.
//
// Heartbeat: a background ticker POSTs the cached clientID set to
// /license/heartbeat every heartbeatInterval. This is what lets the
// License Server re-populate its quota view after a restart — without it,
// LS's view stays empty (since cache hits never re-fetch /sign).
//
// On License Server unreachable / non-200: try stale cache; if no cache,
// return ("", err). Caller (sendMasterSetting) ships zeroed signature and
// the device retries on next reconnect.
type RemoteSigner struct {
serverURL string
token string
offlineGrace time.Duration
httpClient *http.Client
logger Logger
sf singleflight.Group
mu sync.Mutex
cache map[string]cachedSig
hbDone chan struct{}
hbWg sync.WaitGroup
}
type cachedSig struct {
sig string
fetchedAt time.Time
}
// signRequest mirrors the License Server's POST /license/sign body schema.
type signRequest struct {
ClientID string `json:"client_id"`
StartTime string `json:"start_time"`
}
// signResponse mirrors the License Server's response schema.
type signResponse struct {
Signature string `json:"signature,omitempty"`
Error string `json:"error,omitempty"`
}
// nilLogger drops all log lines; used when callers don't pass a logger so
// the RemoteSigner stays safe to use without panic'ing on nil deref.
type nilLogger struct{}
func (nilLogger) Info(string, ...any) {}
func (nilLogger) Warn(string, ...any) {}
func (nilLogger) Error(string, ...any) {}
// heartbeatInterval is the period for /license/heartbeat POSTs. 90s is
// well below the License Server's 5-minute eviction window (quota.go), so
// the customer's devices never get reaped from the LS quota view while
// they're still actively heartbeating to it.
const heartbeatInterval = 90 * time.Second
// ValidateRemoteURL returns nil if u is a safe LICENSE_SERVER URL. We
// require https:// to keep the JWT and signature off the wire in cleartext;
// the only exception is http://localhost / http://127.0.0.1 for testing.
func ValidateRemoteURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("not a URL: %w", err)
}
switch u.Scheme {
case "https":
return nil
case "http":
host := u.Hostname()
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return nil
}
return fmt.Errorf("http:// scheme exposes JWT in cleartext; use https:// (got %q)", raw)
default:
return fmt.Errorf("unsupported scheme %q (need https://)", u.Scheme)
}
}
func NewRemote(serverURL, token string, offlineGrace time.Duration, lg Logger) *RemoteSigner {
if lg == nil {
lg = nilLogger{}
}
r := &RemoteSigner{
serverURL: strings.TrimRight(serverURL, "/"),
token: token,
offlineGrace: offlineGrace,
logger: lg,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
cache: make(map[string]cachedSig),
hbDone: make(chan struct{}),
}
r.hbWg.Add(1)
go r.heartbeatLoop()
return r
}
func (r *RemoteSigner) Sign(startTime, clientID string) (string, error) {
key := startTime + "|" + clientID
// Fresh cache hit: serve from memory, no network.
r.mu.Lock()
if c, ok := r.cache[key]; ok && time.Since(c.fetchedAt) < r.offlineGrace {
sig := c.sig
r.mu.Unlock()
return sig, nil
}
r.mu.Unlock()
// Coalesce concurrent fetches for the same key — protects the License
// Server from herd reconnects after a network blip.
v, err, _ := r.sf.Do(key, func() (any, error) {
return r.fetch(startTime, clientID)
})
if err == nil {
sig := v.(string)
r.mu.Lock()
r.cache[key] = cachedSig{sig: sig, fetchedAt: time.Now()}
r.mu.Unlock()
return sig, nil
}
// Hard failure: fall back to stale cache if any. Better to keep an
// existing device alive than fail closed during a transient outage.
r.mu.Lock()
c, ok := r.cache[key]
r.mu.Unlock()
if ok {
age := time.Since(c.fetchedAt).Round(time.Second)
r.logger.Warn("RemoteSigner: License Server unreachable (%v); serving stale cache (age=%s) for clientID=%s",
err, age, clientID)
return c.sig, nil
}
r.logger.Error("RemoteSigner: License Server unreachable (%v) and no cache for clientID=%s; client will see zeroed signature",
err, clientID)
return "", err
}
func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
body, err := json.Marshal(signRequest{ClientID: clientID, StartTime: startTime})
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", r.serverURL+"/license/sign", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+r.token)
req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
// 401/403: token rejected — likely revoked or expired.
return "", fmt.Errorf("License Server returned %d: %s",
resp.StatusCode, string(respBody))
}
var sr signResponse
if err := json.Unmarshal(respBody, &sr); err != nil {
return "", fmt.Errorf("malformed License Server response: %w", err)
}
if sr.Error != "" {
return "", errors.New(sr.Error)
}
if sr.Signature == "" {
return "", errors.New("License Server returned empty signature")
}
return sr.Signature, nil
}
// heartbeatLoop POSTs the cached clientID set to /license/heartbeat every
// heartbeatInterval. Goal: after a License Server restart, the customer's
// existing devices get re-counted in the LS quota view without each one
// needing to cache-miss /sign first.
func (r *RemoteSigner) heartbeatLoop() {
defer r.hbWg.Done()
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-r.hbDone:
return
case <-ticker.C:
r.sendHeartbeat()
}
}
}
func (r *RemoteSigner) sendHeartbeat() {
// Snapshot the cache's currently-fresh clientIDs.
r.mu.Lock()
cutoff := time.Now().Add(-r.offlineGrace)
ids := make([]string, 0, len(r.cache))
for key, c := range r.cache {
if c.fetchedAt.Before(cutoff) {
continue
}
// key is "startTime|clientID" — extract clientID for the heartbeat.
if _, cid, ok := strings.Cut(key, "|"); ok {
ids = append(ids, cid)
}
}
r.mu.Unlock()
if len(ids) == 0 {
return // nothing to report yet
}
body, err := json.Marshal(heartbeatRequest{
ActiveDeviceCount: len(ids),
ActiveDeviceIDs: ids,
})
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST",
r.serverURL+"/license/heartbeat", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+r.token)
req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req)
if err != nil {
// Transient — don't spam logs every 90s; debug-level if we add one.
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode != http.StatusOK {
r.logger.Warn("RemoteSigner: heartbeat returned %d", resp.StatusCode)
}
}
func (r *RemoteSigner) Mode() string { return "remote" }
func (r *RemoteSigner) Close() error {
select {
case <-r.hbDone:
// already closed
default:
close(r.hbDone)
}
r.hbWg.Wait()
r.httpClient.CloseIdleConnections()
return nil
}

View File

@@ -0,0 +1,283 @@
package licensing
import (
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// LicenseServer is the HTTP service the operator's LocalSigner exposes for
// RemoteSigner customer deployments. It uses the same LocalSigner instance
// (same HMAC master key) to produce signatures so customers can issue
// device logins without ever holding the master key themselves.
//
// Endpoints:
//
// POST /license/sign
// Body: {"client_id": "...", "start_time": "..."}
// Auth: Authorization: Bearer <customer-JWT>
// Reply: {"signature": "<64-hex>"} (200)
// or {"error": "..."} (4xx/5xx)
// Enforces quota: claims.max_devices for the JWT's "sub".
//
// POST /license/heartbeat
// Body: {"active_device_count": N, "active_device_ids": ["...","..."]}
// Auth: Authorization: Bearer <customer-JWT>
// Reply: {"server_view_count": M, "drift": N-M}
// Used by the customer's Go server to surface its view; cross-validated
// against what /sign has actually been asked for under that customer.
// Large drift is logged for anti-tamper review; no automatic revocation
// in v1 — operator decides.
//
// Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT
// "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked.
type LicenseServer struct {
signer *LocalSigner
pubKey *rsa.PublicKey
tracker *quotaTracker
logger Logger
mux *http.ServeMux
}
// Logger is the minimal logging interface we need. The cmd package's
// *logger.Logger satisfies this with its existing Info/Warn methods.
type Logger interface {
Info(format string, args ...any)
Warn(format string, args ...any)
Error(format string, args ...any)
}
// NewLicenseServer builds the HTTP handler set. evictAfter is how long a
// quiet device keeps its slot before its quota is reclaimed (recommend
// 5 min — twice a typical heartbeat interval).
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
evictAfter time.Duration, lg Logger) *LicenseServer {
s := &LicenseServer{
signer: signer,
pubKey: pubKey,
tracker: newQuotaTracker(evictAfter),
logger: lg,
mux: http.NewServeMux(),
}
s.mux.HandleFunc("/license/sign", s.handleSign)
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
return s
}
// Handler returns the http.Handler the operator wires into their HTTP
// server (or runs standalone via http.ListenAndServe).
func (s *LicenseServer) Handler() http.Handler { return s.mux }
// authenticate extracts and verifies the bearer JWT. Returns the parsed
// claims on success; writes the appropriate HTTP error and returns nil
// on failure so the caller can simply `return` on a nil result.
func (s *LicenseServer) authenticate(w http.ResponseWriter, r *http.Request) *LicenseClaims {
authHdr := r.Header.Get("Authorization")
if !strings.HasPrefix(authHdr, "Bearer ") {
writeJSONError(w, http.StatusUnauthorized, "missing Bearer token")
return nil
}
tokenStr := strings.TrimPrefix(authHdr, "Bearer ")
claims, err := VerifyJWT(tokenStr, s.pubKey)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, fmt.Sprintf("invalid token: %v", err))
return nil
}
return claims
}
func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
claims := s.authenticate(w, r)
if claims == nil {
return
}
var req signRequest
if err := readJSONLimited(w, r, maxSignBodyBytes, &req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("bad request body: %v", err))
return
}
if req.ClientID == "" || req.StartTime == "" {
writeJSONError(w, http.StatusBadRequest, "client_id and start_time required")
return
}
// Atomically check + reserve the slot. A rejected request does NOT
// consume a slot — see quotaTracker.Reserve.
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
if !accepted {
s.logger.Warn("License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID)
writeJSONError(w, http.StatusForbidden,
fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices))
return
}
// Mint the signature using the local HMAC master key.
sig, err := s.signer.Sign(req.StartTime, req.ClientID)
if err != nil {
s.logger.Error("License Server: signer failed: %v", err)
writeJSONError(w, http.StatusInternalServerError, "signing failed")
return
}
writeJSON(w, http.StatusOK, signResponse{Signature: sig})
s.logger.Info("License Server: signed for sub=%s clientID=%s active=%d/%d ttl=%s",
claims.Subject, req.ClientID, active, claims.MaxDevices,
formatTTL(claims.ttlSinceNow()))
}
type heartbeatRequest struct {
ActiveDeviceCount int `json:"active_device_count"`
ActiveDeviceIDs []string `json:"active_device_ids"`
}
type heartbeatResponse struct {
ServerViewCount int `json:"server_view_count"`
Drift int `json:"drift"` // customer count - server view count
}
func (s *LicenseServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
claims := s.authenticate(w, r)
if claims == nil {
return
}
var req heartbeatRequest
if err := readJSONLimited(w, r, maxHeartbeatBodyBytes, &req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("bad request body: %v", err))
return
}
// REFRESH-ONLY: only bump activity timestamps for devices already in
// the customer's set (i.e. that came through /sign). New IDs reported
// here are silently ignored — otherwise a malicious customer could
// inflate their quota by POSTing fake IDs to /heartbeat.
refreshed := s.tracker.RefreshExisting(claims.Subject, req.ActiveDeviceIDs)
serverView := len(s.tracker.Snapshot(claims.Subject))
drift := req.ActiveDeviceCount - serverView
// Soft anti-tamper: large persistent drift means the customer reports
// devices we never minted signatures for. Log for operator review; no
// automatic revocation in v1.
if drift > claims.MaxDevices/2 && drift > 5 {
s.logger.Warn("License Server: heartbeat drift sub=%s reported=%d server=%d refreshed=%d drift=%d",
claims.Subject, req.ActiveDeviceCount, serverView, refreshed, drift)
}
writeJSON(w, http.StatusOK, heartbeatResponse{
ServerViewCount: serverView,
Drift: drift,
})
}
// Issue is a small in-process helper for operators to mint customer JWTs
// without spinning up a separate tool. It is intentionally unexported as
// an HTTP endpoint — JWT issuance is a one-off operator action, not a
// remote API. Use it from a cmd/issue-token CLI or interactively.
//
// Returns the signed RS256 token string.
// minTokenTTL guards against fat-finger ttl=0 / negative — those produce
// already-expired tokens that 401 immediately and confuse the customer.
const minTokenTTL = time.Hour
func Issue(privKey *rsa.PrivateKey, sub, tier string, maxDevices int, ttl time.Duration) (string, error) {
if privKey == nil {
return "", errors.New("nil private key")
}
if sub == "" {
return "", errors.New("sub (customer ID) is required")
}
if ttl < minTokenTTL {
return "", fmt.Errorf("ttl too short (%v); minimum is %v", ttl, minTokenTTL)
}
switch tier {
case TierTrial:
// Trial: 0 means "use the default 20" (kept consistent with VerifyJWT).
if maxDevices <= 0 {
maxDevices = TrialMaxDevices
}
case TierPaid:
// Paid: must be explicit. A 0 here is almost certainly a misconfig
// and would silently let one customer use unlimited devices.
if maxDevices <= 0 {
return "", errors.New("paid tier requires explicit max_devices > 0")
}
default:
return "", fmt.Errorf("unsupported tier: %q", tier)
}
now := time.Now()
claims := &LicenseClaims{
Tier: tier,
MaxDevices: maxDevices,
RegisteredClaims: jwt.RegisteredClaims{
Subject: sub,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return tok.SignedString(privKey)
}
// ---- helpers ----
// Body size caps. /sign sends a tiny fixed-schema body; /heartbeat carries
// a list of clientIDs whose worst case is bounded by the customer's max
// device cap (paid is operator-controlled). 64 KiB is roomy for hundreds
// of 20-byte IDs while still bounding the worst case.
const (
maxSignBodyBytes int64 = 8 << 10 // 8 KiB
maxHeartbeatBodyBytes int64 = 64 << 10 // 64 KiB
)
// readJSONLimited wraps the body with http.MaxBytesReader so a misconfigured
// (or malicious) client cannot OOM the server with a multi-GB payload.
// DisallowUnknownFields catches schema drift cleanly.
func readJSONLimited(w http.ResponseWriter, r *http.Request, limit int64, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, limit)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, signResponse{Error: msg})
}
func formatTTL(d time.Duration) string {
if d <= 0 {
return "expired"
}
days := int(d.Hours() / 24)
if days > 0 {
return fmt.Sprintf("%dd", days)
}
return d.Round(time.Minute).String()
}

View File

@@ -0,0 +1,44 @@
// Package licensing provides pluggable signing strategies for the per-login
// CMD_MASTERSETTING signature. Three modes are picked at startup by env var:
//
// - LocalSigner (YAMA_SIGN_PASSWORD set): operator's own deployment.
// Signs directly with the HMAC master key. Optionally serves as License
// Server for RemoteSigner deployments (see server.go) when
// YAMA_LICENSE_HTTP_ADDR is also set.
//
// - RemoteSigner (YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set): customer
// deployments. Never sees the HMAC master key — each new device login
// does an HTTPS POST to the operator's License Server to fetch a
// signature. Successful results are cached for 24h so transient
// License Server outages don't break new logins.
//
// - NoOpSigner (neither set): free-tier mode. Returns empty signature so
// the client's private library refuses screen/file features; device
// list keeps working.
//
// The Sign call is on the per-login critical path — keep it fast.
// LocalSigner is microseconds (HMAC). RemoteSigner is one HTTPS round-trip
// on cache miss (~100-300 ms), zero on cache hit.
package licensing
// Signer produces the per-login signature for CMD_MASTERSETTING.
//
// Sign returns the 64-char lowercase hex HMAC-SHA256 of "<startTime>|<clientID>"
// keyed by the deployment's master HMAC secret. Returning "" means "no
// signature available" — sendMasterSetting will ship a zeroed Signature[64]
// field, and the client's private library will refuse screen/file init.
type Signer interface {
// Sign generates the signature for a new device login. SHOULD be quick
// (sub-300ms target). Returning ("", nil) is allowed and means "this
// deployment doesn't sign" (NoOpSigner). Returning ("", err) means a
// transient failure — callers ship zeroed signature and log the error;
// the device can retry by reconnecting.
Sign(startTime, clientID string) (string, error)
// Mode returns a short identifier for logging: "local", "remote", "noop".
Mode() string
// Close releases background resources (HTTP keepalives, caches).
// Idempotent.
Close() error
}

View File

@@ -0,0 +1,114 @@
package licensing
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
// LicenseClaims is the JWT payload the operator signs and ships to each
// customer. The operator picks "sub" (a unique customer ID), "tier" (trial
// or paid), "max_devices" (concurrent device cap), and "exp" (token
// expiry — independent of any in-memory cache TTL on the RemoteSigner
// side).
type LicenseClaims struct {
Tier string `json:"tier"`
MaxDevices int `json:"max_devices"`
jwt.RegisteredClaims
}
// LoadRSAPublicKey parses an RSA public key from a PEM file. The License
// Server loads this once at startup to verify incoming customer JWTs.
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM
// encodings — openssl emits PKIX by default; "openssl rsa -RSAPublicKey_out"
// emits PKCS#1.
func LoadRSAPublicKey(pemPath string) (*rsa.PublicKey, error) {
data, err := os.ReadFile(pemPath)
if err != nil {
return nil, fmt.Errorf("read public key %s: %w", pemPath, err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block in %s", pemPath)
}
// Try PKIX first (most common output of openssl genrsa | openssl rsa -pubout).
if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("PKIX key in %s is not RSA", pemPath)
}
return rsaPub, nil
}
// Fall back to PKCS#1.
if pub, err := x509.ParsePKCS1PublicKey(block.Bytes); err == nil {
return pub, nil
}
return nil, fmt.Errorf("failed to parse %s as PKIX or PKCS#1 RSA public key", pemPath)
}
// VerifyJWT validates the customer's JWT against the License Server's
// public key. Returns the parsed claims on success. Caller enforces the
// tier-specific quota using claims.MaxDevices.
//
// Validation done by jwt.ParseWithClaims:
// - signature (RS256, using the supplied public key)
// - "exp" claim (expiry)
// - "iat" / "nbf" if present
//
// We additionally require tier ∈ {trial, paid}, max_devices > 0, and "sub"
// non-empty.
func VerifyJWT(tokenStr string, pubKey *rsa.PublicKey) (*LicenseClaims, error) {
claims := &LicenseClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
// Lock to exactly RS256. The wider *SigningMethodRSA check (which
// would also accept RS384/RS512) is technically still safe because
// all RSA variants require the private key — but pinning the exact
// alg matches the docs and avoids surprises if Issue() ever changes.
// "alg":"none" tampering is blocked because none isn't SigningMethodRS256.
if t.Method != jwt.SigningMethodRS256 {
return nil, fmt.Errorf("unexpected JWT alg: %v (need RS256)", t.Header["alg"])
}
return pubKey, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid JWT")
}
if claims.Subject == "" {
return nil, errors.New("JWT missing 'sub' claim")
}
switch claims.Tier {
case TierTrial:
if claims.MaxDevices <= 0 {
claims.MaxDevices = TrialMaxDevices
}
case TierPaid:
if claims.MaxDevices <= 0 {
return nil, errors.New("paid-tier JWT missing max_devices")
}
default:
return nil, fmt.Errorf("unsupported tier: %q", claims.Tier)
}
return claims, nil
}
// ttlSinceNow returns the time.Duration until exp; useful for logging.
func (c *LicenseClaims) ttlSinceNow() time.Duration {
if c.ExpiresAt == nil {
return 0
}
return time.Until(c.ExpiresAt.Time)
}