diff --git a/.gitignore b/.gitignore index 0cd6d8c..fb25571 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ Bin/* nul server/go/web/assets/index.html server/go/users.json +server/go/build/ diff --git a/server/go/README.md b/server/go/README.md index c32f5ed..cdaac09 100644 --- a/server/go/README.md +++ b/server/go/README.md @@ -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. | `` | +| `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key,直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `` | +| `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 颁发的客户 JWT(RS256),作为 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 `,回 `{"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` | 20(JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 | +| `paid` | JWT 必须显式指定 | 长 TTL token | + ## 使用示例 完整的 TCP + Hub + Web 集成示例就是 [`cmd/main.go`](cmd/main.go),那是程序入口本身、也是最权威的范例 —— 包含 handler 装配、hub 注册、web HTTP/WS 服务、信号优雅关闭等。 diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 0f75ba5..aba47d6 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -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() diff --git a/server/go/go.mod b/server/go/go.mod index 6cb5c13..9bfa5d7 100644 --- a/server/go/go.mod +++ b/server/go/go.mod @@ -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 ) diff --git a/server/go/go.sum b/server/go/go.sum index bab1d66..46cea6e 100644 --- a/server/go/go.sum +++ b/server/go/go.sum @@ -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= diff --git a/server/go/licensing/factory.go b/server/go/licensing/factory.go new file mode 100644 index 0000000..1b62df7 --- /dev/null +++ b/server/go/licensing/factory.go @@ -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. +// +// 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 +} diff --git a/server/go/licensing/licensing_test.go b/server/go/licensing/licensing_test.go new file mode 100644 index 0000000..93ba005 --- /dev/null +++ b/server/go/licensing/licensing_test.go @@ -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") + } +} diff --git a/server/go/licensing/local.go b/server/go/licensing/local.go new file mode 100644 index 0000000..02a7f30 --- /dev/null +++ b/server/go/licensing/local.go @@ -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 } diff --git a/server/go/licensing/noop.go b/server/go/licensing/noop.go new file mode 100644 index 0000000..a309e98 --- /dev/null +++ b/server/go/licensing/noop.go @@ -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 } diff --git a/server/go/licensing/quota.go b/server/go/licensing/quota.go new file mode 100644 index 0000000..8899dc0 --- /dev/null +++ b/server/go/licensing/quota.go @@ -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 "aTracker{ + 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 can be admitted under +// without exceeding , 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 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 +// that is ALREADY in '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 . 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 +} diff --git a/server/go/licensing/remote.go b/server/go/licensing/remote.go new file mode 100644 index 0000000..0e13ec5 --- /dev/null +++ b/server/go/licensing/remote.go @@ -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 +} diff --git a/server/go/licensing/server.go b/server/go/licensing/server.go new file mode 100644 index 0000000..9205fb9 --- /dev/null +++ b/server/go/licensing/server.go @@ -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 +// 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 +// 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() +} + diff --git a/server/go/licensing/signer.go b/server/go/licensing/signer.go new file mode 100644 index 0000000..f867a22 --- /dev/null +++ b/server/go/licensing/signer.go @@ -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 "|" +// 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 +} diff --git a/server/go/licensing/token.go b/server/go/licensing/token.go new file mode 100644 index 0000000..db5aa8a --- /dev/null +++ b/server/go/licensing/token.go @@ -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) +}