diff --git a/.agent/rules/golang.md b/.agent/rules/golang.md index f981daa6..b0306efa 100644 --- a/.agent/rules/golang.md +++ b/.agent/rules/golang.md @@ -21,7 +21,6 @@ paths: type GatewayDeps struct { Hub *gateway.Hub SM *session.Manager - JWTValidator *security.JWTValidator Bridge *gateway.Bridge } diff --git a/.agent/rules/security.md b/.agent/rules/security.md index e3aeaf1d..788d4d06 100644 --- a/.agent/rules/security.md +++ b/.agent/rules/security.md @@ -8,30 +8,19 @@ paths: > Mutex / 反模式规范 → 见 AGENTS.md 约定与规范 -## JWT 认证 +## Bot ID 传输 -### 必须使用 ES256 签名 -```go -if token.Method.Alg() != "ES256" { - return ErrUnauthorized -} -``` - -### Claims 完整性 -JWT 必须包含:`iss`、`sub`、`aud`、`exp`、`iat`、`jti` + `role`、`scope`、`bot_id`、`session_id` +Bot ID 通过 `X-Bot-ID` HTTP header 或 `bot_id` query param 传输。服务端通过 `security.BotIDFromRequest(r)` 提取,无需 JWT。 -### Token 生命周期 -| 类型 | TTL | -|------|-----| -| Access Token | 5min | -| Gateway Token | 1h | -| Refresh Token | 7d | +### 信任边界 -### JTI 黑名单(TTL 缓存) -被撤销的 Token jti 必须进入内存黑名单,超时后自动清理。JTI 生成禁止 `math/rand`,必须用 `crypto/rand`。 +Bot ID **未与 API Key 密码学绑定**——任何已认证客户端可指定任意 bot ID。这是可接受的设计: +1. Bot ID 仅决定路由行为(使用哪套 bot 配置),不决定授权 +2. API Key 认证已在连接层网关限制访问 +3. 跨 Bot 数据隔离由下游 session key 派生强制执行 ### 多 Bot 隔离 -Token 中的 `bot_id` 必须与请求的 Session 所属 Bot 精确匹配,禁止跨 Bot 操作。 +连接中的 `botID` 必须与请求的 Session 所属 Bot 精确匹配,禁止跨 Bot 操作。 --- diff --git a/.agent/skills/hotplex-arch-analyzer/SKILL.md b/.agent/skills/hotplex-arch-analyzer/SKILL.md index 24e58262..f7be9b84 100644 --- a/.agent/skills/hotplex-arch-analyzer/SKILL.md +++ b/.agent/skills/hotplex-arch-analyzer/SKILL.md @@ -414,7 +414,7 @@ internal/worker — 基础 worker, proc manager internal/worker/opencodeserver — OCS 单例 + worker internal/config — Viper 配置, 热重载 internal/agentconfig — Agent 个性/上下文加载器 -internal/security — JWT, SSRF, 路径安全, 命令白名单 +internal/security — API Key, Bot ID, SSRF, 路径安全, 命令白名单 internal/admin — Admin API 处理器 internal/aep — AEP v1 编解码器 internal/cli — Checker 注册表, onboard 向导 diff --git a/.agent/skills/hotplex-docs-patrol/references/doc-registry.md b/.agent/skills/hotplex-docs-patrol/references/doc-registry.md index d3b54405..f8673294 100644 --- a/.agent/skills/hotplex-docs-patrol/references/doc-registry.md +++ b/.agent/skills/hotplex-docs-patrol/references/doc-registry.md @@ -123,7 +123,7 @@ Viper 配置加载、热重载、三级继承、审计回滚。 ## Security (`internal/security/`) -JWT 认证、SSRF 防御、路径安全、白名单。 +API Key + Bot ID 认证、SSRF 防御、路径安全、白名单。 → `explanation/security-model.md` — 安全模型 → `guides/developer/security-model.md` — 开发者安全指南 diff --git a/.agent/skills/hotplex-release/SKILL.md b/.agent/skills/hotplex-release/SKILL.md index 9cc68516..8fad5f01 100644 --- a/.agent/skills/hotplex-release/SKILL.md +++ b/.agent/skills/hotplex-release/SKILL.md @@ -122,7 +122,7 @@ git log "${LAST_TAG}..HEAD" --no-merges --format="%h %s%n%b---" | `slack`, `feishu`, `messaging`, `stt` | **Messaging** | | `webchat`, `ui`, `chat` | **WebChat UI** | | `config`, `agent-config` | **Configuration** | -| `security`, `jwt`, `ssrf` | **Security** | +| `security`, `auth`, `ssrf` | **Security** | | `cli`, `onboard`, `doctor` | **CLI** | | `client`, `sdk`, `ts`, `python`, `java` | **SDK** | | `test`, `ci`, `build`, `makefile` | **Infrastructure** | diff --git a/.agent/skills/hotplex-setup/SKILL.md b/.agent/skills/hotplex-setup/SKILL.md index 53c28fb4..5ecdc24a 100644 --- a/.agent/skills/hotplex-setup/SKILL.md +++ b/.agent/skills/hotplex-setup/SKILL.md @@ -81,9 +81,9 @@ hotplex doctor --json |---------|---------|------| | `exists` | config.yaml 不存在 | 运行 `hotplex onboard` 生成 | | `syntax` | YAML 解析错误 | 检查缩进和语法,参考 `configs/config.yaml` | -| `required` | JWT secret 缺失或无平台启用 | 运行 `hotplex onboard` 或手动设置 | +| `required` | API Key 缺失或无平台启用 | 运行 `hotplex onboard` 或手动设置 | | `values` | 端口无效或数据目录不存在 | 创建目录或修改端口配置 | -| `env_vars` | JWT_SECRET/ADMIN_TOKEN 未设置 | 在 `.env` 中添加 | +| `env_vars` | ADMIN_TOKEN 未设置 | 在 `.env` 中添加 | #### dependencies(依赖) @@ -96,7 +96,6 @@ hotplex doctor --json | checker | 失败原因 | 处理 | |---------|---------|------| -| `jwt_strength` | JWT secret 太短或低熵 | `openssl rand -base64 48` 重新生成 | | `admin_token` | Token 为空或弱默认值 | 替换为强随机值 | | `file_permissions` | 配置文件权限过宽 | `chmod 600 ~/.hotplex/.env ~/.hotplex/config.yaml` | | `env_in_git` | .env 被 git 追踪 | `git rm --cached .env` | @@ -201,7 +200,7 @@ hotplex onboard hotplex doctor ``` -`hotplex onboard` 自动处理:Go/OS/磁盘检查、JWT/Token 生成、Slack/飞书配置、Worker 选择、config.yaml/.env 生成、Agent 配置模板、STT/TTS 检查、系统服务安装。 +`hotplex onboard` 自动处理:Go/OS/磁盘检查、Slack/飞书配置、Worker 选择、config.yaml/.env 生成、Agent 配置模板、STT/TTS 检查、系统服务安装。 **非交互模式**(CI/自动化): ```bash diff --git a/.agent/skills/hotplex-setup/references/troubleshooting.md b/.agent/skills/hotplex-setup/references/troubleshooting.md index 83fb278c..9e401b4a 100644 --- a/.agent/skills/hotplex-setup/references/troubleshooting.md +++ b/.agent/skills/hotplex-setup/references/troubleshooting.md @@ -148,7 +148,6 @@ hotplex service restart | checker | 问题 | 修复 | |---------|------|------| -| `jwt_strength` | JWT secret 太弱 | `openssl rand -base64 48` 重新生成 | | `admin_token` | 弱默认值 | 替换为强随机值 | | `env_in_git` | .env 被 git 追踪 | `git rm --cached .env` | diff --git a/AGENTS.md b/AGENTS.md index 943358c3..2bba2d10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,6 @@ - ❌ `sync.Mutex` 嵌入或传指针 - ❌ `math/rand` 用于加密 - ❌ Shell 执行(仅允许 `claude` 二进制) -- ❌ 非 ES256 JWT - ❌ 硬编码路径分隔符 - ❌ 直接使用 POSIX 信号 - ❌ 用 `sed`/`awk` 插入或修改源码行(缩进不可控,必须用 Edit 工具) @@ -138,7 +137,7 @@ **支撑模块**: - `config/` - Viper 配置 + 热重载 + 继承 + 审计/回滚。消息层共享默认值(WorkerType, STT, TTS)通过 `FillFrom()` 传播到平台配置。三级优先级:platform > messaging > Default()。多 bot 支持:`SlackBotConfig`/`FeishuBotConfig` + `normalizeSlackBots`/`normalizeFeishuBots` 向后兼容归一化 -- `security/` - JWT、SSRF、路径安全 +- `security/` - API Key 认证(`Authenticator`)、Bot ID 提取(`BotIDFromRequest`)、SSRF 防护、路径安全 - `skills/` - Skills 发现 - `metrics/` - Prometheus 指标 - `service/` - 跨平台系统服务管理(systemd/launchd/SCM) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a61daea..f90d449b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog +## [Unreleased] + +### Summary + +移除 JWT 认证依赖,替换为 API Key + Bot ID 认证模型。这是一次 **Breaking Change**,影响所有 WebSocket 客户端和 SDK 用户。 + +### Breaking Changes + +#### 认证模型变更 + +- **移除 JWT (ES256) 认证**:不再支持 `Authorization: Bearer ` 头。所有客户端必须使用 `X-API-Key` 头传递 API key。(#467) +- **Bot ID 传输变更**:不再通过 JWT `bot_id` claim 传递。改为 `X-Bot-ID` HTTP 头或 `bot_id` query param。服务端通过 `security.BotIDFromRequest(r)` 提取。(#467) +- **浏览器 WebSocket 客户端**:init envelope 的 `auth.token` 字段语义从 JWT 变为 API key(deferred auth)。(#467) +- **环境变量**:`HOTPLEX_JWT_SECRET` 已移除。改用 `HOTPLEX_SECURITY_API_KEY_*` 编号式环境变量。(#467) +- **用户身份**:JWT `sub` claim 的自动 per-user 隔离不再可用。默认身份为 `api_user`。多用户隔离需配置 `APIKeyResolver`(`security.SetKeyResolver()`)将 API key 映射到用户身份。(#467) +- **`--strict` 标志**:`hotplex config validate --strict` 已移除(用于检查 JWT secret 是否设置,不再适用)。 + +#### SDK Breaking Changes + +| SDK | 移除 | 替代 | +|-----|------|------| +| Go Client | `AuthToken()` option, `token.go`, `gen-token/` | `BotID()` option — 发送 `X-Bot-ID` 头 | +| Java Client | `JwtTokenGenerator`, `.tokenGenerator()`, jjwt/bcprov 依赖 | `.apiKey()` + `.botId()` — 发送 `X-Bot-ID` 头 | +| TypeScript Client | `generate-test-token.ts`, `jose` 依赖 | `authToken` 字段语义改为 API key(deferred browser auth) | +| Python Client | — | `auth_token` 参数语义改为 API key | + +#### 配置 API 变更 + +- `config.Load(path, LoadOptions{})` → `config.Load(path)` — 移除 `LoadOptions` 和 `SecretsProvider` 管道(`EnvSecretsProvider`, `ChainedSecretsProvider`)。配置加载仅通过 Viper `AutomaticEnv` 绑定 `HOTPLEX_*` 环境变量。 +- `config.NewWatcher(log, path, sp, store, ...)` → `config.NewWatcher(log, path, store, ...)` — 移除 `SecretsProvider` 参数。 +- `security.NewAuthenticator(cfg, jwtValidator)` → `security.NewAuthenticator(cfg)` — 移除 JWT validator 参数。 +- `security.BotIDFromHeader(r)` → `security.BotIDFromRequest(r)` — 重命名以反映其同时读取 header 和 query param。 + +### Removed + +- `internal/security/jwt.go` — JWT 验证器(ES256 签名、HKDF 密钥派生、JTI 黑名单)。(#467) +- `client/token.go` — Go SDK JWT token 生成。(#467) +- `client/scripts/gen-token/` — Go SDK token 生成命令行工具。(#467) +- `client/examples/08_token_generator/` — Go SDK token 生成示例。(#467) +- `internal/cli/checkers/security_fix_test.go` — JWT 强度检查测试。(#467) +- `examples/typescript-client/scripts/generate-test-token.ts` — TS SDK JWT token 生成脚本。(#467) +- `examples/java-client/src/main/java/dev/hotplex/security/JwtTokenGenerator.java` — Java SDK JWT 生成器。(#467) +- `golang-jwt/jwt/v5` 依赖 — 不再需要。(#467) +- `jjwt-api/impl/jackson` + `bcprov-jdk18on` 依赖(Java SDK) — 不再需要。(#467) + +### Changed + +- `security.AuthenticateRequest()` 内 `BotIDFromRequest` 调用从 2 次优化为 1 次(提取到局部变量)。(#467) +- `conn.go` 中 `context.TODO()` 替换为 `context.Background()`(与同函数其他路径一致)。(#467) + +### Migration Guide + +**1. 环境变量替换:** +```bash +# 旧(已移除) +export HOTPLEX_JWT_SECRET="$(openssl rand -base64 32)" + +# 新 +export HOTPLEX_SECURITY_API_KEY_1="your-api-key" +export HOTPLEX_ADMIN_TOKEN_1="your-admin-token" +``` + +**2. Go SDK 迁移:** +```go +// 旧 +client.New(ctx, URL("ws://localhost:8888/ws"), WorkerType("claude_code"), AuthToken("jwt-token")) + +// 新 +client.New(ctx, URL("ws://localhost:8888/ws"), WorkerType("claude_code"), BotID("bot-123")) +``` + +**3. 多用户隔离:** 如果之前依赖 JWT `sub` 实现用户隔离,需在 Gateway 启动时配置 `APIKeyResolver`: +```go +auth.SetKeyResolver(security.NewChainResolver(dbResolver, configResolver)) +``` + +**4. 浏览器 WebSocket:** init envelope 中 `auth.token` 从传 JWT 改为传 API key,`auth.bot_id` 保持不变。 + ## [1.17.0] - 2026-05-21 ### Summary diff --git a/INSTALL.md b/INSTALL.md index fe200c2b..89e24356 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -176,8 +176,8 @@ hotplex onboard | Variable | Purpose | Generate | |----------|---------|----------| -| `HOTPLEX_JWT_SECRET` | ES256 JWT signing key | `openssl rand -base64 32` | | `HOTPLEX_ADMIN_TOKEN_1` | Admin API bearer token | `openssl rand -base64 32` | +| `HOTPLEX_SECURITY_API_KEY_1` | Client auth key | `openssl rand -base64 32` | ### Config File @@ -240,7 +240,7 @@ hotplex gateway status # check running curl http://localhost:9999/admin/health # 5. WebSocket endpoint -# ws://localhost:8888 (needs API key or JWT) +# ws://localhost:8888 (needs API key) ``` ## Next Steps @@ -292,7 +292,7 @@ gh repo star hrygo/hotplex | `Checksum mismatch` | Corrupted download | Re-run install; if persists, report issue | | `GitHub API rate limit` | Too many unauth API calls | Use `--release ` instead of `--latest` | | Permission denied on `/usr/local` | Non-root user writing to system dir | Use `sudo` or `--prefix ~/.local` | -| `hotplex` runs but won't start gateway | Missing secrets | Run `hotplex onboard` or set `HOTPLEX_JWT_SECRET` | +| `hotplex` runs but won't start gateway | Missing secrets | Run `hotplex onboard` or set `HOTPLEX_ADMIN_TOKEN_1` | | Port 8888/9999 in use | Another process bound | Change `gateway.addr` / `admin.addr` in config | | Windows: PATH not updated | Terminal not refreshed | Open new PowerShell/CMD window | diff --git a/README.md b/README.md index bf6c4de6..b39878d2 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ ### 🛡️ Security & Reliability - 🛡️ **Meta-Cognition Hardening** — Constitutional **META-COGNITION** promoted to the top of B-channel with built-in **XML Sanitizer** to block prompt injection. -- 🔒 **Enterprise-Grade Security** — JWT ES256 authentication, SSRF protection, and **Windows File-Based Injection** to bypass cmd.exe escaping traps. +- 🔒 **Enterprise-Grade Security** — API Key + Bot ID authentication, SSRF protection, and **Windows File-Based Injection** to bypass cmd.exe escaping traps. ### 📱 Multi-Platform Delivery & Integration - 📱 **Cross-Platform Delivery** — **"Write Once, Deploy Anywhere"**. Bridge agents to Web, Slack (Socket Mode), and Feishu (WebSocket) with zero code changes. diff --git a/README_zh.md b/README_zh.md index 24f541dd..55ec4f32 100644 --- a/README_zh.md +++ b/README_zh.md @@ -37,7 +37,7 @@ ### 🛡️ 安全加固与可靠性 - 🛡️ **元认知防御基线** — 宪法级 **META-COGNITION** 迁移至 B 通道首位,内置 **XML Sanitizer** 防护,彻底阻断 Prompt 注入与 XML 结构破坏。 -- 🔒 **企业级安全加固** — 强制 JWT ES256 认证、SSRF 防护、Windows 临时文件式注入(规避 cmd 转义陷阱)及进程级隔离。 +- 🔒 **企业级安全加固** — API Key + Bot ID 认证、SSRF 防护、Windows 临时文件式注入(规避 cmd 转义陷阱)及进程级隔离。 ### 📱 多平台分发与集成 - 📱 **跨平台分发能力** — **"一次接入,全端覆盖"**。无需修改 Agent 代码即可秒级分发至 Web、Slack (Socket Mode) 和飞书。 diff --git a/SECURITY.md b/SECURITY.md index 31d14712..eefd2259 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,7 +32,7 @@ We will acknowledge receipt of your report within **48 hours** and provide a tim - **TLS**: Always enable TLS (`security.tls_enabled: true`) in production. - **Authentication**: Configure strong `APIKeys` and `Admin.Tokens`. -- **JWT**: Use the `JWTValidator` for user-level session authentication. +- **API Key**: Use `Authenticator` for API key validation. Bot ID: Use `BotIDFromRequest(r)` for multi-bot isolation via X-Bot-ID header. - **PGID Isolation**: Ensure the gateway process has sufficient permissions to manage process groups (PGID). --- diff --git a/client/AGENTS.md b/client/AGENTS.md index f30a0be2..6800f1ab 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -8,13 +8,12 @@ Standalone Go module (github.com/hrygo/hotplex/client) for connecting to HotPlex client.go # Client struct: Connect, Resume, SendInput, SendPermissionResponse, SendQuestionResponse, SendElicitationResponse, SendControl, SendReset, SendGC, Close, Events events.go # Typed event constants (EventMessageDelta, EventDone, etc.) + data helpers (AsDoneData, AsErrorData, AsToolCallData) options.go # Functional options (AutoReconnect, ClientSessionID, Metadata, Logger, PingInterval) -token.go # TokenGenerator for JWT creation ``` ## WHERE TO LOOK | Task | Location | Notes | |------|----------|-------| -| Client struct | `client.go:33` | url, workerType, authToken, apiKey, state machine | +| Client struct | `client.go:33` | url, workerType, botID, apiKey, state machine | | Connect flow | `client.go:93` | WebSocket dial → send init → recv init_ack → start pumps | | Resume session | `client.go:99` | Reconnect with existing session_id | | Event stream | `client.go:193` | Events() returns read-only channel of Event structs | @@ -23,7 +22,7 @@ token.go # TokenGenerator for JWT creation | Heartbeat | `client.go:353` | pingPump: periodic ping at DefaultPingInterval | | Event constants | `events.go` | 18+ typed constants matching pkg/events Kind values | | Event data helpers | `events.go` | AsDoneData(), AsErrorData(), AsToolCallData(), etc. | -| Functional options | `options.go` | URL(), WorkerType(), AuthToken(), AutoReconnect(), ClientSessionID(), Metadata() | +| Functional options | `options.go` | URL(), WorkerType(), BotID(), AutoReconnect(), ClientSessionID(), Metadata() | ## KEY PATTERNS @@ -43,7 +42,7 @@ token.go # TokenGenerator for JWT creation - Data helpers: `evt.AsDoneData()`, `evt.AsErrorData()`, `evt.AsToolCallData()` for type-safe access **Functional options pattern** -- `client.New(ctx, URL(...), WorkerType(...), AuthToken(...), AutoReconnect(true))` +- `client.New(ctx, URL(...), WorkerType(...), BotID(...), AutoReconnect(true))` - `ClientSessionID("my-session-001")` for UUIDv5 deterministic mapping - `Metadata(map[string]any{"key": "val"})` for init handshake metadata diff --git a/client/README.md b/client/README.md index 4abdebe4..58ec9f4f 100644 --- a/client/README.md +++ b/client/README.md @@ -73,7 +73,7 @@ Functional options pattern, passed to `New`: ```go client.URL("ws://localhost:8888") // required client.WorkerType("claude_code") // required -client.AuthToken("jwt-token") // JWT bearer token +client.BotID("bot-123") // Bot ID for multi-bot setups client.APIKey("sk-xxx") // API key header client.PingInterval(30 * time.Second) // heartbeat (default 54s) client.ClientSessionID("my-session-001") // client-managed session ID (UUIDv5 mapped) @@ -200,17 +200,15 @@ StateTerminated // worker exited StateDeleted // GC'd ``` -## Token Generation +## Bot ID (Multi-Bot Setup) ```go -gen, err := client.NewTokenGenerator(signingKey) -if err != nil { /* ... */ } - -// Key formats: PEM file path, 64-char hex, or 44-char base64 -token, err := gen.Generate("user-id", []string{"read", "write"}, 1*time.Hour) - -// Custom audience (default "gateway") -gen.WithAudience("custom-aud") +c, err := client.New(ctx, + client.URL("ws://localhost:8888"), + client.WorkerType("claude_code"), + client.APIKey("ak-xxx"), + client.BotID("bot-123"), // specify target Bot ID +) ``` ## Examples @@ -219,13 +217,12 @@ gen.WithAudience("custom-aud") |------|-------------| | [`examples/quickstart.go`](examples/quickstart.go) | Minimal connect & chat | | [`examples/complete.go`](examples/complete.go) | Full features: permissions, stats, resume | -| [`scripts/gen-token/main.go`](scripts/gen-token/main.go) | JWT token generator CLI | Run an example: ```bash cd client -HOTPLEX_SIGNING_KEY= go run examples/quickstart.go +HOTPLEX_API_KEY= go run examples/quickstart.go ``` ## Related diff --git a/client/client.go b/client/client.go index be84ef7f..bfae90f7 100644 --- a/client/client.go +++ b/client/client.go @@ -33,7 +33,7 @@ type Client struct { // config from options url string workerType string - authToken string + botID string apiKey string clientSessionID string @@ -193,8 +193,8 @@ func (c *Client) Resume(ctx context.Context, sessionID string) (*InitAckData, er func (c *Client) doConnect(ctx context.Context, sessionID string, isResume bool) (*InitAckData, error) { hdr := http.Header{} - if c.authToken != "" { - hdr.Set("Authorization", "Bearer "+c.authToken) + if c.botID != "" { + hdr.Set("X-Bot-ID", c.botID) } if c.apiKey != "" { hdr.Set("X-API-Key", c.apiKey) @@ -226,8 +226,8 @@ func (c *Client) doConnect(ctx context.Context, sessionID string, isResume bool) if c.metadata != nil { initData["config"] = map[string]any{"metadata": c.metadata} } - if c.authToken != "" { - initData["auth"] = map[string]any{"token": c.authToken} + if c.botID != "" { + initData["auth"] = map[string]any{"bot_id": c.botID} } if c.clientSessionID != "" || isResume { initData["session_id"] = sessionID diff --git a/client/examples/08_token_generator/main.go b/client/examples/08_token_generator/main.go deleted file mode 100644 index 5d03a385..00000000 --- a/client/examples/08_token_generator/main.go +++ /dev/null @@ -1,67 +0,0 @@ -// 08_token_generator — Standalone JWT token generation utility. -// -// Generates ES256 JWT tokens for HotPlex Gateway authentication. -// Supports PEM file, 64-char hex, or 44-char base64 key formats. -// -// Usage: -// -// HOTPLEX_SIGNING_KEY= go run ./08_token_generator -// HOTPLEX_SIGNING_KEY= go run ./08_token_generator -sub user-123 -scopes admin,write -ttl 2h -v -package main - -import ( - "flag" - "fmt" - "os" - "strings" - "time" - - client "github.com/hrygo/hotplex/client" -) - -func main() { - subject := flag.String("sub", "example-user", "JWT subject (user ID)") - scopes := flag.String("scopes", "read,write", "Comma-separated scopes") - ttl := flag.Duration("ttl", 1*time.Hour, "Token TTL (e.g. 1h, 30m, 24h)") - audience := flag.String("aud", "gateway", "JWT audience") - showClaims := flag.Bool("v", false, "Print decoded claims") - flag.Parse() - - keyStr := os.Getenv("HOTPLEX_SIGNING_KEY") - if keyStr == "" && flag.NArg() > 0 { - keyStr = flag.Arg(0) - } - if keyStr == "" { - fmt.Fprintln(os.Stderr, "Usage: HOTPLEX_SIGNING_KEY= go run ./08_token_generator [flags]") - fmt.Fprintln(os.Stderr, "\nKey formats: PEM file path, 64-char hex, or 44-char base64") - fmt.Fprintln(os.Stderr, "\nFlags:") - flag.PrintDefaults() - os.Exit(1) //nolint:gocritic // example exit - } - - gen, err := client.NewTokenGenerator(keyStr) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to load key: %v\n", err) - os.Exit(1) //nolint:gocritic // example exit - } - - token, err := gen.WithAudience(*audience).Generate(*subject, strings.Split(*scopes, ","), *ttl) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to generate token: %v\n", err) - os.Exit(1) //nolint:gocritic // example exit - } - - fmt.Println(token) - - if *showClaims { - fmt.Println() - fmt.Println("==================================================") - fmt.Println(" Token Info") - fmt.Println("==================================================") - fmt.Printf("Subject: %s\n", *subject) - fmt.Printf("Scopes: %s\n", *scopes) - fmt.Printf("Audience: %s\n", *audience) - fmt.Printf("TTL: %s\n", *ttl) - fmt.Printf("Expires: %s\n", time.Now().Add(*ttl).Format(time.RFC3339)) - } -} diff --git a/client/examples/09_production/main.go b/client/examples/09_production/main.go index 149c5e49..cc040256 100644 --- a/client/examples/09_production/main.go +++ b/client/examples/09_production/main.go @@ -1,13 +1,13 @@ // 09_production — Full production-grade integration example. // -// Combines: JWT/API Key auth, session resume, signal handling, +// Combines: API Key auth, optional bot ID, session resume, signal handling, // streaming output, tool permission policy, usage statistics, // and graceful shutdown. // // Usage: // // HOTPLEX_API_KEY=test-api-key go run ./09_production -// HOTPLEX_SIGNING_KEY= go run ./09_production +// HOTPLEX_BOT_ID= go run ./09_production // HOTPLEX_SESSION_ID= go run ./09_production # resume existing session package main @@ -45,28 +45,14 @@ type sessionStats struct { func main() { gatewayURL := demo.EnvOr("HOTPLEX_GATEWAY_URL", "ws://localhost:8888/ws") - signingKey := demo.EnvOr("HOTPLEX_SIGNING_KEY", "") apiKey := demo.EnvOr("HOTPLEX_API_KEY", "") + botID := demo.EnvOr("HOTPLEX_BOT_ID", "") sessionID := os.Getenv("HOTPLEX_SESSION_ID") workerType := demo.EnvOr("HOTPLEX_WORKER_TYPE", "claude_code") task := demo.EnvOr("HOTPLEX_TASK", "List the files in the current directory and count them.") - // Auth: JWT, API Key, or none (for dev). - var authToken string - if signingKey != "" { - gen, err := client.NewTokenGenerator(signingKey) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: token generator: %v\n", err) - os.Exit(1) //nolint:gocritic // example exit - } - token, err := gen.Generate("production-user", []string{"read", "write"}, 1*time.Hour) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: generate token: %v\n", err) - os.Exit(1) //nolint:gocritic // example exit - } - authToken = token - fmt.Println("Auth: JWT") - } else if apiKey != "" { + // Auth: API Key or none (for dev). + if apiKey != "" { fmt.Println("Auth: API Key") } else { fmt.Println("Auth: none (development mode)") @@ -75,23 +61,18 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Default to test-api-key if no auth provided (development). - if apiKey == "" && authToken == "" { - apiKey = "test-api-key" - } - opts := []client.Option{ client.URL(gatewayURL), client.WorkerType(workerType), client.AutoReconnect(true), client.Logger(slog.Default()), } - if authToken != "" { - opts = append(opts, client.AuthToken(authToken)) - } if apiKey != "" { opts = append(opts, client.APIKey(apiKey)) } + if botID != "" { + opts = append(opts, client.BotID(botID)) + } c, err := client.New(ctx, opts...) if err != nil { diff --git a/client/go.mod b/client/go.mod index 62b89355..5aa36db9 100644 --- a/client/go.mod +++ b/client/go.mod @@ -3,8 +3,7 @@ module github.com/hrygo/hotplex/client go 1.26 require ( - github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/google/uuid v1.6.0 + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 ) diff --git a/client/go.sum b/client/go.sum index 42984328..4766763c 100644 --- a/client/go.sum +++ b/client/go.sum @@ -1,7 +1,5 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= diff --git a/client/options.go b/client/options.go index f05fa432..99ec7f1b 100644 --- a/client/options.go +++ b/client/options.go @@ -24,10 +24,11 @@ func WorkerType(t string) Option { } } -// AuthToken sets the JWT auth token for gateway authentication. -func AuthToken(token string) Option { +// BotID sets the bot identifier for multi-bot isolation. +// Sent as X-Bot-ID header during WebSocket upgrade and in the init envelope. +func BotID(id string) Option { return func(c *Client) error { - c.authToken = token + c.botID = id return nil } } diff --git a/client/scripts/gen-token/main.go b/client/scripts/gen-token/main.go deleted file mode 100644 index c80d8dfe..00000000 --- a/client/scripts/gen-token/main.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build ignore - -// gen-token generates ES256 JWT tokens for HotPlex Gateway authentication. -package main - -import ( - "flag" - "fmt" - "os" - "strings" - "time" - - client "github.com/hrygo/hotplex/client" -) - -var ( - flagSubject = flag.String("sub", "example-user", "JWT subject (user ID)") - flagScopes = flag.String("scopes", "read,write", "comma-separated scopes") - flagTTL = flag.Duration("ttl", 1*time.Hour, "token TTL (e.g. 1h, 30m)") - flagAudience = flag.String("aud", "gateway", "JWT audience") - flagBotID = flag.String("bot-id", "", "Bot ID for isolation (SEC-007)") -) - -func main() { - flag.Parse() - - keyStr := os.Getenv("HOTPLEX_SIGNING_KEY") - if keyStr == "" && flag.NArg() > 0 { - keyStr = flag.Arg(0) - } - if keyStr == "" { - fmt.Fprintln(os.Stderr, "Usage: HOTPLEX_SIGNING_KEY= go run gen-token/main.go") - fmt.Fprintln(os.Stderr, " or: go run gen-token/main.go /path/to/key.pem") - fmt.Fprintln(os.Stderr, "Key formats: PEM file, 64-char hex, or 44-char base64") - os.Exit(1) - } - - gen, err := client.NewTokenGenerator(keyStr) - if err != nil { - fmt.Fprintf(os.Stderr, "create token generator: %v\n", err) - os.Exit(1) - } - - _ = gen.WithAudience(*flagAudience) - if *flagBotID != "" { - _ = gen.WithBotID(*flagBotID) - } - - token, err := gen.Generate(*flagSubject, strings.Split(*flagScopes, ","), *flagTTL) - if err != nil { - fmt.Fprintf(os.Stderr, "generate token: %v\n", err) - os.Exit(1) - } - fmt.Println(token) -} diff --git a/client/token.go b/client/token.go deleted file mode 100644 index 01b3e8da..00000000 --- a/client/token.go +++ /dev/null @@ -1,123 +0,0 @@ -package client - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/hkdf" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/pem" - "errors" - "fmt" - "math/big" - "os" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" -) - -// TokenGenerator creates ES256 JWT tokens for gateway authentication. -type TokenGenerator struct { - privateKey *ecdsa.PrivateKey - issuer string - audience string - botID string -} - -// NewTokenGenerator creates a TokenGenerator from an ECDSA P-256 private key. -// The key can be provided as: -// - A PEM-encoded private key file (path or HOTPLEX_SIGNING_KEY env var value) -// - A 64-character hex string (32 raw bytes, hex-decoded) -// - A 44-character base64 string (32 raw bytes) -func NewTokenGenerator(keyOrPath string) (*TokenGenerator, error) { - if keyOrPath == "" { - return nil, errors.New("token: signing key is empty") - } - key, err := loadKey(keyOrPath) - if err != nil { - return nil, err - } - return &TokenGenerator{privateKey: key, issuer: "hotplex", audience: "gateway"}, nil -} - -// WithAudience sets a custom JWT audience (default "gateway"). -func (g *TokenGenerator) WithAudience(aud string) *TokenGenerator { - g.audience = aud - return g -} - -// WithBotID sets a custom Bot ID claim for isolation (default ""). -func (g *TokenGenerator) WithBotID(id string) *TokenGenerator { - g.botID = id - return g -} - -// Generate creates a JWT token for the given subject and scopes. -func (g *TokenGenerator) Generate(subject string, scopes []string, ttl time.Duration) (string, error) { - now := time.Now() - claims := jwt.MapClaims{ - "iss": g.issuer, - "sub": subject, - "aud": g.audience, - "exp": now.Add(ttl).Unix(), - "iat": now.Unix(), - "nbf": now.Unix(), - "jti": uuid.NewString(), - "scopes": scopes, - } - if g.botID != "" { - claims["bot_id"] = g.botID - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - return token.SignedString(g.privateKey) -} - -func loadKey(keyOrPath string) (*ecdsa.PrivateKey, error) { - // Try as PEM file. - if data, err := os.ReadFile(keyOrPath); err == nil { - block, _ := pem.Decode(data) - if block != nil { - key, err := jwt.ParseECPrivateKeyFromPEM(block.Bytes) - if err == nil { - return key, nil - } - } - } - - // Try as 64-char hex string (32 raw bytes, hex-decoded). - if len(keyOrPath) == 64 { - decoded, err := hex.DecodeString(keyOrPath) - if err == nil { - return deriveECDSAP256Key(decoded), nil - } - } - - // Try as raw 32-byte base64 (URL-safe or standard). - if decoded, err := base64.URLEncoding.DecodeString(keyOrPath); err == nil && len(decoded) == 32 { - return deriveECDSAP256Key(decoded), nil - } - if decoded, err := base64.StdEncoding.DecodeString(keyOrPath); err == nil && len(decoded) == 32 { - return deriveECDSAP256Key(decoded), nil - } - - return nil, fmt.Errorf("token: unrecognized key format (expected PEM file, 64-char hex, or 44-char base64): %q", keyOrPath) -} - -// deriveECDSAP256Key mirrors internal/security/jwt.go:deriveECDSAP256Key. -func deriveECDSAP256Key(seed []byte) *ecdsa.PrivateKey { - scalarBytes, err := hkdf.Key(sha256.New, seed, nil, "hotplex-ecdsa-p256", 32) - if err != nil { - panic("hkdf.Key: " + err.Error()) - } - s := new(big.Int).SetBytes(scalarBytes) - N := elliptic.P256().Params().N - s.Mod(s, new(big.Int).Sub(N, big.NewInt(1))) - s.Add(s, big.NewInt(1)) - x, y := elliptic.P256().ScalarBaseMult(s.Bytes()) //nolint:staticcheck // SA1019: must use deprecated scalar multiplication for deterministic ECDSA key derivation from seed - return &ecdsa.PrivateKey{ - PublicKey: ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, - D: s, - } -} diff --git a/cmd/hotplex/config_cmd.go b/cmd/hotplex/config_cmd.go index 40de6690..79b34423 100644 --- a/cmd/hotplex/config_cmd.go +++ b/cmd/hotplex/config_cmd.go @@ -19,17 +19,14 @@ func newConfigCmd() *cobra.Command { func newConfigValidateCmd() *cobra.Command { var configPath string - var strict bool cmd := &cobra.Command{ Use: "validate", Short: "Validate configuration file", Long: "Validate the configuration file without starting the gateway.\n" + - "Checks YAML syntax, required fields, and value constraints.\n" + - "Use --strict to also verify that required secrets (JWT, admin tokens) are set.", + "Checks YAML syntax, required fields, and value constraints.", Example: ` hotplex config validate # Validate default config - hotplex config validate -c /path/to/config.yaml - hotplex config validate --strict # Also check secrets`, + hotplex config validate -c /path/to/config.yaml`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig(configPath, false) if err != nil { @@ -41,12 +38,6 @@ func newConfigValidateCmd() *cobra.Command { fmt.Fprintf(os.Stderr, " ⚠ %s\n", w) } - if strict { - if err := cfg.RequireSecrets(); err != nil { - return err - } - } - if len(warns) > 0 { fmt.Fprintf(os.Stderr, "\nConfiguration loaded with %d warning(s).\n", len(warns)) } else { @@ -56,6 +47,5 @@ func newConfigValidateCmd() *cobra.Command { }, } configFlag(cmd, &configPath) - cmd.Flags().BoolVar(&strict, "strict", false, "also verify required secrets are set") return cmd } diff --git a/cmd/hotplex/gateway_run.go b/cmd/hotplex/gateway_run.go index e85e8176..98e8378b 100644 --- a/cmd/hotplex/gateway_run.go +++ b/cmd/hotplex/gateway_run.go @@ -142,7 +142,7 @@ func runGateway(configPath string, devMode bool, stopCh <-chan struct{}) (err er var configWatcher *config.Watcher if configPath != "" { - configWatcher = config.NewWatcher(log, configPath, nil, cfgStore, + configWatcher = config.NewWatcher(log, configPath, cfgStore, func(newCfg *config.Config) { log.Info("config: hot reload applied", "gateway_addr", newCfg.Gateway.Addr, @@ -188,11 +188,7 @@ func runGateway(configPath string, devMode bool, stopCh <-chan struct{}) (err er _ = hub.SendToSession(ctx, env) } - var jwtValidator *security.JWTValidator - if len(cfg.Security.JWTSecret) > 0 { - jwtValidator = security.NewJWTValidator(cfg.Security.JWTSecret, cfg.Security.JWTAudience) - } - auth := security.NewAuthenticator(&cfg.Security, jwtValidator) + auth := security.NewAuthenticator(&cfg.Security) // API key → user identity resolver: YAML config takes priority over DB (Admin API CRUD). // ChainResolver tries config map first, falls back to DB. Either source may be empty. @@ -238,7 +234,6 @@ func runGateway(configPath string, devMode bool, stopCh <-chan struct{}) (err er Hub: hub, SM: sm, Auth: auth, - JWTValidator: jwtValidator, Bridge: bridge, SkillsLocator: skillsLocator, }) @@ -484,11 +479,11 @@ loop: if err != nil { log.Error("gateway: server failed, exiting", "err", err) cancel() - shutdownGateway(ctx, log, deps, msgAdapters, server, adminServer, jwtValidator, skillsLocator, pidTracker, cleanupWG, cronScheduler) + shutdownGateway(ctx, log, deps, msgAdapters, server, adminServer, skillsLocator, pidTracker, cleanupWG, cronScheduler) return err } cancel() - shutdownGateway(ctx, log, deps, msgAdapters, server, adminServer, jwtValidator, skillsLocator, pidTracker, cleanupWG, cronScheduler) + shutdownGateway(ctx, log, deps, msgAdapters, server, adminServer, skillsLocator, pidTracker, cleanupWG, cronScheduler) return nil case <-stopCh: log.Info("gateway: shutdown", "signal", "stopCh") @@ -497,7 +492,7 @@ loop: } cancel() - shutdownGateway(ctx, log, deps, msgAdapters, server, adminServer, jwtValidator, skillsLocator, pidTracker, cleanupWG, cronScheduler) + shutdownGateway(ctx, log, deps, msgAdapters, server, adminServer, skillsLocator, pidTracker, cleanupWG, cronScheduler) return nil } @@ -632,7 +627,6 @@ func shutdownGateway( msgAdapters []messaging.PlatformAdapterInterface, server *http.Server, adminServer *http.Server, - jwtValidator *security.JWTValidator, skillsLocator *skills.Locator, pidTracker *proc.Tracker, cleanupWG *sync.WaitGroup, @@ -684,10 +678,6 @@ func shutdownGateway( deps.Bridge.Shutdown(shutdownCtx) - if jwtValidator != nil { - jwtValidator.Stop() - } - cleanupWG.Wait() pidTracker.RemoveAll() @@ -728,7 +718,7 @@ func loadConfig(configPath string, devMode bool) (*config.Config, error) { loadEnvFile(filepath.Dir(absPath)) - cfg, err := config.Load(absPath, config.LoadOptions{}) + cfg, err := config.Load(absPath) if err != nil { return nil, fmt.Errorf("config: load %q: %w", absPath, err) } diff --git a/cmd/hotplex/security.go b/cmd/hotplex/security.go index 5bca3f29..3dedee26 100644 --- a/cmd/hotplex/security.go +++ b/cmd/hotplex/security.go @@ -22,7 +22,7 @@ func newSecurityCmd() *cobra.Command { Use: "security", Short: "Run security audit", Long: `Run a security audit on your HotPlex configuration. -Checks TLS settings, SSRF protection, JWT configuration, and access policies. +Checks TLS settings, SSRF protection, and access policies. Use --fix to automatically resolve issues where possible.`, Example: ` hotplex security # Run security audit hotplex security -v # Verbose output @@ -77,7 +77,7 @@ func checkTLSConfig(_ context.Context, cfgPath string) cli.Diagnostic { } } - cfg, err := config.Load(cfgPath, config.LoadOptions{}) + cfg, err := config.Load(cfgPath) if err != nil { return cli.Diagnostic{ Name: name, @@ -132,7 +132,7 @@ func checkSSRFConfig(_ context.Context, cfgPath string) cli.Diagnostic { } } - cfg, err := config.Load(cfgPath, config.LoadOptions{}) + cfg, err := config.Load(cfgPath) if err != nil { return cli.Diagnostic{ Name: name, diff --git a/cmd/hotplex/service_install.go b/cmd/hotplex/service_install.go index f67303b5..c4ef53a2 100644 --- a/cmd/hotplex/service_install.go +++ b/cmd/hotplex/service_install.go @@ -40,7 +40,7 @@ func newServiceInstallCmd() *cobra.Command { loadEnvFile(filepath.Dir(configPath)) - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return fmt.Errorf("load config: %w", err) } diff --git a/cmd/hotplex/status.go b/cmd/hotplex/status.go index b229a529..8a2b34de 100644 --- a/cmd/hotplex/status.go +++ b/cmd/hotplex/status.go @@ -98,7 +98,7 @@ func gatewayHealthURL(configPath string) string { return defaultHealthURL } loadEnvFile(filepath.Dir(absPath)) - cfg, err := config.Load(absPath, config.LoadOptions{}) + cfg, err := config.Load(absPath) if err != nil { return defaultHealthURL } diff --git a/cmd/hotplex/yaml_verify_test.go b/cmd/hotplex/yaml_verify_test.go index 05ecdc4e..c8e858bd 100644 --- a/cmd/hotplex/yaml_verify_test.go +++ b/cmd/hotplex/yaml_verify_test.go @@ -22,7 +22,7 @@ func TestEmbeddedConfigYAMLIntegrity(t *testing.T) { "broadcast_queue_size: 256", "rate_limit_enabled: true", "wal_mode: true", "busy_timeout: 5s", "api_key_header:", "tls_enabled: false", - "jwt_audience:", "retention_period: 168h", "gc_scan_interval: 1m", + "retention_period: 168h", "gc_scan_interval: 1m", "max_concurrent: 1000", "min_size: 0", "max_size: 100", "max_idle_per_user: 5", "max_memory_per_user: 3221225472", "max_lifetime: 24h", "execution_timeout: 30m", diff --git a/configs/README.md b/configs/README.md index 466cb461..ea35e62f 100644 --- a/configs/README.md +++ b/configs/README.md @@ -20,7 +20,7 @@ configs/ ## 快速开始 ```bash -cp configs/env.example ~/.hotplex/.env # 填入 HOTPLEX_JWT_SECRET、HOTPLEX_ADMIN_TOKEN_1 +cp configs/env.example ~/.hotplex/.env # 填入 HOTPLEX_ADMIN_TOKEN_1 make dev # 自动使用 config-dev.yaml ``` @@ -34,7 +34,6 @@ make dev # 自动使用 config-dev.yaml | 2 | 父级配置文件 | `inherits` 递归加载,支持多级继承与循环检测 | | 3 | 当前配置文件 | `-config` 指定的 YAML | | 4 | 环境变量 `HOTPLEX_*` | Viper AutomaticEnv + `applyMessagingEnv()` 手动映射 | -| 5 | Secrets Provider | JWT/Token 等敏感字段,仅此渠道 | 环境变量映射公式:`HOTPLEX_
_`,全大写下划线连接。 例如 `pool.max_size` → `HOTPLEX_POOL_MAX_SIZE`。 @@ -115,13 +114,11 @@ log: | 字段 | 类型 | 默认值 | 热重载 | 说明 | |:-----|:-----|:-------|:------:|:-----| | `api_key_header` | string | `X-API-Key` | — | API Key 认证的 HTTP 头名称。客户端通过此头发送 API Key,Hub 在 WebSocket 升级前校验 | -| `api_keys` | []string | `[]` | ✅ | 允许访问的 API 密钥列表。通过 `HOTPLEX_SECURITY_API_KEY_1..N` 编号式环境变量设置。为空时不做 API Key 校验(依赖 JWT 或网络策略保护)。热重载时原子替换整个 key 集合,不影响进行中的请求 | +| `api_keys` | []string | `[]` | ✅ | 允许访问的 API 密钥列表。通过 `HOTPLEX_SECURITY_API_KEY_1..N` 编号式环境变量设置。为空时不做 API Key 校验(依赖网络策略保护)。热重载时原子替换整个 key 集合,不影响进行中的请求 | | `tls_enabled` | bool | `false` | — | 启用 TLS(WSS)。生产环境**必须**设为 `true`。启用后网关使用 `tls_cert_file` 和 `tls_key_file` 加载证书 | | `tls_cert_file` | string | `/etc/hotplex/tls/server.crt` | — | TLS 证书文件路径。仅当 `tls_enabled: true` 时使用 | | `tls_key_file` | string | `/etc/hotplex/tls/server.key` | — | TLS 私钥文件路径。仅当 `tls_enabled: true` 时使用 | | `allowed_origins` | []string | `["*"]` | ✅ | CORS 允许的 Origin 列表。WebSocket 升级时 `Upgrader.CheckOrigin` 校验请求的 Origin 头。`["*"]` 允许所有来源(仅开发用),生产应限制为具体域名。热重载即时生效——每次 WS 升级请求读取最新配置 | -| `jwt_audience` | string | `hotplex-gateway` | — | JWT `aud` 声明的期望值。用于验证令牌的目标受众,防止令牌跨服务复用 | -| `jwt_secret` | []byte | — | — | JWT 签名密钥(ES256)。**仅**通过 `HOTPLEX_JWT_SECRET` 环境变量提供(base64 编码),禁止写入 YAML。用于签发和验证 session token | ### session — 会话生命周期 @@ -284,7 +281,6 @@ Worker 进程启动时的工作目录遵循以下优先级覆盖逻辑: | 变量 | 必填 | 说明 | |:-----|:----:|:-----| -| `HOTPLEX_JWT_SECRET` | **是** | JWT 签名密钥(base64 编码,ES256 算法) | | `HOTPLEX_ADMIN_TOKEN_1` | **是** | 主管理端令牌 | | `HOTPLEX_ADMIN_TOKEN_2..N` | 否 | 备用管理端令牌(轮转用) | | `HOTPLEX_SECURITY_API_KEY_1..N` | 否 | 客户端 API 密钥 | @@ -331,7 +327,7 @@ Watcher 监听 `-config` 指定文件的变更(500ms 防抖),通过反射 | gateway | `broadcast_queue_size` | Go channel 大小在 make 时确定 | | log | `format` | slog Handler 在初始化时确定格式 | | db | `path` / `wal_mode` | SQLite 连接在启动时建立 | -| security | `tls_*` / `jwt_secret` | TLS 证书和 JWT 密钥在启动时加载 | +| security | `tls_*` | TLS 证书在启动时加载 | > 变更不可热重载字段时,Watcher 会记录日志 `config: static field changed, restart required`,新值存入 ConfigStore 但不产生实际效果,需重启网关才能生效。 @@ -362,7 +358,6 @@ Watcher 监听 `-config` 指定文件的变更(500ms 防抖),通过反射 ## 生产安全清单 -- `jwt_secret` 仅通过 `HOTPLEX_JWT_SECRET` 设置,禁止写入 YAML - `admin.tokens` 仅通过 `HOTPLEX_ADMIN_TOKEN_1..N` 设置 - 生产必须 `tls_enabled: true` - `admin.addr` 绑定内网或通过 `allowed_cidrs` 限制访问 diff --git a/configs/config-prod.yaml b/configs/config-prod.yaml index 548b768d..1ffe5547 100644 --- a/configs/config-prod.yaml +++ b/configs/config-prod.yaml @@ -4,7 +4,6 @@ # Usage: ./hotplex -config configs/config-prod.yaml # # Required env vars: -# HOTPLEX_JWT_SECRET — JWT signing key (ES256) # HOTPLEX_ADMIN_TOKEN_1 — Admin API token # # Only fields that differ from base are listed below. diff --git a/configs/config.yaml b/configs/config.yaml index 98c4341f..808bbb03 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -3,7 +3,7 @@ # Sensible defaults for both dev and prod. Override via: # 1. Inheritance: config-dev.yaml / config-prod.yaml (inherits this file) # 2. Environment variables: HOTPLEX_* (highest priority, auto-detected if key exists here) -# 3. Secrets provider: JWT secret, admin tokens (never in config files) +# 3. Secrets provider: admin tokens (never in config files) # # Code defaults live in internal/config/config.go:Default(). @@ -89,7 +89,6 @@ security: tls_key_file: "/etc/hotplex/tls/server.key" allowed_origins: - "*" - jwt_audience: "hotplex-gateway" # Work directory security settings (convention + configuration) # Program defaults (convention over configuration): diff --git a/configs/env.example b/configs/env.example index 1b836bfa..aba8e426 100644 --- a/configs/env.example +++ b/configs/env.example @@ -9,9 +9,6 @@ # ── Required Secrets ────────────────────────────────────────────────────────── -# JWT signing key (ES256). Generate: openssl rand -base64 32 | tr -d '\n' -HOTPLEX_JWT_SECRET= - # Admin API tokens. Referenced by config-dev.yaml as ${ADMIN_TOKEN}. # Also supports _1..N env vars for rotation: HOTPLEX_ADMIN_TOKEN_1, HOTPLEX_ADMIN_TOKEN_2, ... # Generate: openssl rand -hex 32 diff --git a/docs/architecture/AEP-v1-Protocol.md b/docs/architecture/AEP-v1-Protocol.md index 04ed2743..ec4e7195 100644 --- a/docs/architecture/AEP-v1-Protocol.md +++ b/docs/architecture/AEP-v1-Protocol.md @@ -91,7 +91,7 @@ title: Agent Event Protocol (AEP) v1 "worker_type": "claude_code", "session_id": "sess_xxx", "auth": { - "token": "" + "token": "" }, "config": { "model": "claude-sonnet-4-6", @@ -116,7 +116,7 @@ title: Agent Event Protocol (AEP) v1 | `version` | 是 | 协议版本,必须为 `aep/v1`(同时存在于 Envelope 层和 data 层) | | `worker_type` | 是 | Worker 类型标识(如 `claude_code`、`opencode_server`) | | `session_id` | 否 | 有值 = resume 已有 session;空 = 创建新 session | -| `auth` | 否 | 鉴权载荷(非浏览器或无需 Cookie 环境必传,包含 JWT 等 Token 认证信息) | +| `auth` | 否 | 鉴权载荷(非浏览器或无需 Cookie 环境必传,包含 API Key 认证信息) | | `config` | 否 | Worker 配置 | | `client_caps` | 否 | Client 能力声明 | diff --git a/docs/architecture/Platform-Messaging-Architecture-Diagrams.md b/docs/architecture/Platform-Messaging-Architecture-Diagrams.md index bf64b044..78970315 100644 --- a/docs/architecture/Platform-Messaging-Architecture-Diagrams.md +++ b/docs/architecture/Platform-Messaging-Architecture-Diagrams.md @@ -324,7 +324,7 @@ PlatformConn 接口 (messaging/platform_conn.go) | | WS 客户端 | Slack 适配器 | 飞书适配器 | |----------------|---------------|----------------------|------------------| | 连接建立 | WS 握手 | Socket Mode SDK | larkws SDK | -| 认证 | JWT (网关层) | App Token (SDK 层) | App Token + 刷新 | +| 认证 | API Key (网关层) | App Token (SDK 层) | App Token + 刷新 | | Session ID | client UUID | `slack:{team}:{ch}:{user}` | `feishu:{chat}:{user}` | | 消息去重 | WS 无重复 | ClientMsgID dedup | message_id dedup | | 流式输出 | WS 实时推送 | chat.update debounce | CardKit (无 QPS 限制) | @@ -348,7 +348,7 @@ PlatformConn 接口 (messaging/platform_conn.go) | `worker/worker.go` | 0 行 | 零改动 | 消息经 Handler 路由到现有 Worker | | `pkg/events/` | 0 行 | 零改动 | 所有字段通用 | | `gateway/conn.go` | 0 行 | 零改动 | Conn.WriteCtx/Close 与 PlatformConn 一致 | -| `security/jwt.go` | 0 行 | 零改动 | Platform 认证走平台 SDK | +| `security/auth.go` | 0 行 | 零改动 | Platform 认证走平台 SDK | ### 7.2 潜在风险 @@ -356,7 +356,7 @@ PlatformConn 接口 (messaging/platform_conn.go) |----------------------|------|-------------------------------------------------| | pcEntry 存为 `*Conn` 类型 | 低 | 考虑将 `h.sessions` key 显式改为接口 | | Platform BotID 为空 | 中 | messaging 包注入 sentinel botID (`"slack"`) | -| OwnerID 由 Adapter 预填 | 中 | 从 JWTValidator 获取 signed platform token | +| OwnerID 由 Adapter 预填 | 中 | 从 Authenticator 获取 signed platform token | ### 7.3 依赖方向 @@ -364,7 +364,7 @@ PlatformConn 接口 (messaging/platform_conn.go) 新 messaging/ 包 ──引用──▶ 现有包 (单向依赖) platform_conn.go → pkg/events bridge.go → internal/gateway (Hub, Handler) - internal/security (JWTValidator) + internal/security (Authenticator) pkg/aep, pkg/events slack/adapter.go → github.com/slack-go/slack feishu/adapter.go → github.com/larksuite/oapi-sdk-go/v2 diff --git a/docs/architecture/Platform-Messaging-Extension.md b/docs/architecture/Platform-Messaging-Extension.md index 41665e9c..efb0b0d4 100644 --- a/docs/architecture/Platform-Messaging-Extension.md +++ b/docs/architecture/Platform-Messaging-Extension.md @@ -1219,7 +1219,7 @@ func (a *Adapter) streamCardContent(ctx context.Context, cardID, elementID, text | 维度 | WebSocket 客户端 | Slack | 飞书 | |------|-----------------|-------|------| | 连接建立 | 客户端主动 WS 握手 | Socket Mode SDK 连接 Slack | larkws SDK 连接飞书 | -| 认证 | JWT 握手(网关层) | App Token(SDK 层) | App Token + Token 刷新 | +| 认证 | API Key(网关层) | App Token(SDK 层) | App Token + Token 刷新 | | 会话 ID | client 生成 UUID | `slack:{team}:{channel}:{thread_ts}:{user}` | `feishu:{chat_id}:{thread_ts}:{user_id}` | | 消息去重 | WebSocket 本身无重复 | ClientMsgID dedup | message_id dedup | | 流式更新 | WebSocket 实时推送 | SDK 原生 `StartStream/AppendStream/StopStream` | CardKit `v1` 流式 API(50 次/秒) | @@ -1310,7 +1310,7 @@ func (a *Adapter) streamCardContent(ctx context.Context, cardID, elementID, text ### R7: Handler.Handle 所有权验证 -**风险**: WS 模式下 OwnerID 由 JWT 验证填充。平台模式下 OwnerID 从哪里来? +**风险**: WS 模式下 OwnerID 由 API Key 验证填充。平台模式下 OwnerID 从哪里来? **缓解**: PlatformAdapter 在调用 `Bridge.Handle()` 前,验证 SDK 级别的用户身份(Slack user token / 飞书 user access token),并将 user_id 作为 OwnerID 填入 Envelope。平台适配器是受信的内部组件。 --- diff --git a/docs/architecture/WebSocket-Full-Duplex-Flow.md b/docs/architecture/WebSocket-Full-Duplex-Flow.md index a64c4da0..612ec5ab 100644 --- a/docs/architecture/WebSocket-Full-Duplex-Flow.md +++ b/docs/architecture/WebSocket-Full-Duplex-Flow.md @@ -24,7 +24,7 @@ title: WebSocket Full-Duplex Communication Flow │ │ 1️⃣ WebSocket Upgrade │ GET /ws?session_id=xxx - │ Authorization: Bearer + │ X-API-Key: ▼ ┌──────────────────────────────────────────────────────────────────────────────────────┐ │ HotPlex Worker Gateway (Go) │ @@ -100,7 +100,7 @@ title: WebSocket Full-Duplex Communication Flow │ │ 2️⃣ stdio / Process Spawn │ - Environment Variables Injection - │ - JWT Token Passing + │ - API Key Auth │ - Session Context ▼ ┌──────────────────────────────────────────────────────────────────────────────────────┐ @@ -151,7 +151,7 @@ title: WebSocket Full-Duplex Communication Flow │══ 2.Handshake ══════════════════════════════════════════════════════════════│ │ │ │ │ │── {init} ─────►│ │ │ - │ JWT Token │── Validate ─────────│ │ + │ API Key │── Authenticate ───────│ │ │ │◄── OK ──────────────│ │ │ │── Create Session ──►│ │ │ │ │ │ @@ -247,7 +247,7 @@ title: WebSocket Full-Duplex Communication Flow AEP Event Type Gateway Handler Claude Code ───────────────────────────────────────────────────────────────── - init ─────────► Validate JWT N/A + init ─────────► Authenticate N/A input ─────────► Parse & Route ─────────► stdin delta ◄───────── Format & Send stdout done ◄───────── Format & Send stdout diff --git a/docs/architecture/Worker-Gateway-Design.md b/docs/architecture/Worker-Gateway-Design.md index 972fbcef..a6f4a8cd 100644 --- a/docs/architecture/Worker-Gateway-Design.md +++ b/docs/architecture/Worker-Gateway-Design.md @@ -1065,7 +1065,7 @@ SELECT * FROM sessions WHERE state != 'deleted'; | 策略 | 说明 | | --------------------------- | --------------------------------------------------------------------------- | | **Upgrade 阶段认证**(MVP) | WebSocket 握手时通过 query param 或 header 携带 API Key,验证通过后建立连接 | -| **Per-message JWT**(v1.1) | 每个 Envelope 携带 JWT,支持细粒度权限控制 | +| **Per-message auth**(v1.1) | 每个 Envelope 携带 auth token,支持细粒度权限控制 | | **Token 刷新**(v1.1) | 通过 `control.refresh_token` 在不断开连接的情况下刷新凭证 | > **参考**: Slack RTM 使用 Upgrade 阶段 token 认证。Discord Gateway 在 init 握手中携带 token。 diff --git a/docs/explanation/brain-llm-orchestration.md b/docs/explanation/brain-llm-orchestration.md index 6f115457..c232042a 100644 --- a/docs/explanation/brain-llm-orchestration.md +++ b/docs/explanation/brain-llm-orchestration.md @@ -175,7 +175,6 @@ AI 分析(`deepInputAnalysis`)用于检测正则无法捕获的变体攻击 - API Keys(`api_key=xxx`) - AWS Access Keys(`AKIA...`) - Private Keys(`-----BEGIN RSA PRIVATE KEY-----`) -- JWT Tokens(`eyJ...`) - 内网 IP 地址(`10.x`、`172.16-31.x`、`192.168.x`) - 数据库连接字符串(`postgres://user:pass@host`) - 密码(`password=xxx`) diff --git a/docs/explanation/security-model.md b/docs/explanation/security-model.md index d1e28aff..2c8872b8 100644 --- a/docs/explanation/security-model.md +++ b/docs/explanation/security-model.md @@ -1,7 +1,7 @@ --- title: 安全模型 weight: 6 -description: HotPlex Gateway 安全设计哲学与多层防护体系:白名单策略、JWT 认证、SSRF 防御与进程隔离 +description: HotPlex Gateway 安全设计哲学与多层防护体系:白名单策略、API Key 认证、SSRF 防御与进程隔离 --- # 安全模型 @@ -27,7 +27,7 @@ HotPlex Gateway 的安全模型遵循两条核心原则: ├─────────────────────────────────────┤ │ Layer 3: Input Validation │ Envelope 校验、XML Sanitizer ├─────────────────────────────────────┤ -│ Layer 2: Authentication │ API Key + JWT ES256 +│ Layer 2: Authentication │ API Key + X-Bot-ID ├─────────────────────────────────────┤ │ Layer 1: Protocol Security │ AEP 版本协商、命令白名单 └─────────────────────────────────────┘ @@ -42,10 +42,54 @@ HotPlex Gateway 的安全模型遵循两条核心原则: ### Layer 2:Authentication(认证) -- **API Key**:Gateway 级别的访问控制。支持两种解析模式: - - **默认模式**:所有有效 Key 映射到 `api_user`(单用户场景) - - **企业模式**(APIKeyResolver):通过数据库映射将 Key 关联到具体用户身份,实现多用户 Session 隔离。支持 `MapResolver`(配置文件)和 `DBResolver`(SQLite)两种实现 -- **JWT**:Session 级别的身份验证,携带 `user_id`、`bot_id`、`scopes` +认证采用 **API Key + Bot ID** 双字段模型,实现网关级别访问控制与多 Bot 隔离。 + +#### 传输方式 + +| 通道 | API Key | Bot ID | 适用场景 | +|------|---------|--------|---------| +| HTTP Header | `X-API-Key` | `X-Bot-ID` | REST API、CLI、服务端客户端 | +| Query Param | `api_key` | `bot_id` | 浏览器 WebSocket(无法发送自定义 Header) | +| Init Envelope | `auth.token` | `auth.bot_id` | 浏览器 WebSocket 延迟认证 | + +#### 认证流程 + +**标准客户端(HTTP Header)**: + +``` +Client ──X-API-Key──> HTTP Upgrade ──> Authenticator.AuthenticateRequest() + X-Bot-ID ├─ 提取 API Key(header 优先,query param 兜底) + ├─ 校验 Key 合法性(恒定时间比较) + ├─ 解析 Bot ID + └─ 返回 (userID, botID, nil) 或 ErrUnauthorized +``` + +**浏览器 WebSocket 客户端(延迟认证)**: + +``` +Browser ──WS Upgrade (no headers)──> Hub.HandleHTTP + └─ pendingAuth = true(标记延迟认证) + +Browser ──init envelope──> Conn.ReadPump + auth.token ├─ ExtractAPIKey 失败 → 拒绝 + auth.bot_id ├─ AuthenticateKey 校验 token + ├─ 提取 bot_id + └─ 认证成功,清除 pendingAuth +``` + +浏览器 WebSocket 客户端因 CORS 限制无法发送自定义 HTTP Header,因此认证被延迟到首帧 `init` Envelope。服务端在 `HandleHTTP` 阶段检测到无 API Key 时设置 `pendingAuth` 标记,待 `ReadPump` 收到 `init` 后从 `auth.token` 字段提取并校验。 + +#### Dev 模式 + +当未配置任何 API Key 时,所有请求以 `"anonymous"` 身份放行,无需认证。这一行为在 `AuthenticateRequest` 和 `AuthenticateKey` 中均有处理:`len(validKey) == 0` 时直接返回 `"anonymous"`。 + +#### API Key 到用户身份的映射 + +默认情况下,所有合法 Key 的用户身份为 `"api_user"`。可通过 `SetKeyResolver` 注入自定义映射(如从数据库关联 API Key 到具体用户 ID)。 + +#### Bot ID 与多租户隔离 + +Bot ID 通过 `security.BotIDFromRequest(r)` 从 `X-Bot-ID` Header 或 `bot_id` Query Param 中提取。连接中的 `botID` 必须与 Session 所属 Bot 精确匹配,跨 Bot 操作被 Session 层拒绝。 ### Layer 3:Input Validation(输入验证) @@ -67,32 +111,6 @@ HotPlex Gateway 的安全模型遵循两条核心原则: - 嵌套 Agent 防护(`StripNestedAgent`) - Permission 交互协议,敏感操作需人类审批 -## 为什么只用 ES256 JWT - -HotPlex 强制使用 **ES256**(ECDSA P-256)签名算法: - -### 非对称优势 - -- **公钥分发**:验证 Token 只需公钥,私钥永远不出现在 Gateway 配置之外的任何地方 -- **Bot 隔离**:每个 Bot 使用独立的密钥对,`bot_id` 嵌入 JWT claims,实现天然的多租户隔离 -- **密钥泄露影响范围**:单个私钥泄露只影响对应的 Bot,不影响整个系统 - -### 对称算法的问题 - -HS256 等对称算法的密钥既用于签名又用于验证。如果密钥泄露,攻击者可以伪造任何 Bot 的 Token,破坏多租户隔离。 - -### ES256 在代码中的强制执行 - -```go -// jwt.go — 拒绝所有非 ES256 算法 -switch token.Method.Alg() { -case "ES256": - return publicKey, nil -default: - return nil, fmt.Errorf("rejected signing method: %v (only ES256 is allowed)", alg) -} -``` - ## 为什么使用命令白名单 HotPlex 仅允许执行两个二进制:`claude` 和 `opencode`。 @@ -185,7 +203,6 @@ Layer 4: IP 段检查 → 所有解析结果与 BlockedCIDRs 比对 | 维度 | Dev 模式 | 生产模式 | |------|---------|---------| | API Key | 未配置时允许所有请求(`anonymous`) | 必须配置,所有请求需认证 | -| JWT | 可选 | 强制 | | SSRF | 标准检查 | 标准 + Double Resolve | | Tool 限制 | 所有 Tool | 仅 Safe 类 | | Bash 策略 | P1 改为警告 | P1 严格阻止 | @@ -197,5 +214,5 @@ Layer 4: IP 段检查 → 所有解析结果与 BlockedCIDRs 比对 ## 相关实践 -- [安全模型操作指南](../guides/developer/security-model.md) — 7 层安全体系的日常配置与审计 -- [安全策略参数参考](../reference/security-policies.md) — JWT、SSRF、命令白名单、工具控制的完整参数 +- [安全模型操作指南](../guides/developer/security-model.md) — 5 层安全体系的日常配置与审计 +- [安全策略参数参考](../reference/security-policies.md) — API Key、SSRF、命令白名单、工具控制的完整参数 diff --git a/docs/explanation/why-hotplex.md b/docs/explanation/why-hotplex.md index 6b54640f..191b5339 100644 --- a/docs/explanation/why-hotplex.md +++ b/docs/explanation/why-hotplex.md @@ -78,7 +78,7 @@ Future Agent ────┘ ### 企业级安全控制 -- **JWT 认证** — ES256 签名,session ownership 强校验 +- **API Key + Bot ID 认证** — 简单安全的静态密钥认证 - **SSRF 防护** — URL 白名单 + IP 阻断 + DNS 重绑定防御 - **命令审批** — Agent 执行敏感操作前需要用户确认(Permission Hook) - **输入审计** — Safety Guard 检测威胁指令,防止 prompt injection @@ -127,7 +127,7 @@ Agent 是 Worker,Worker 是黑盒。Gateway 不关心 Worker 内部实现, | 需要远程调用 Agent | 通过 Slack/飞书随时随地交互 | | 团队共享 AI 能力 | 统一入口、统一配置、统一审计 | | 自动化 AI 工作流 | AI-native cron 替代手写脚本 | -| 企业合规要求 | JWT/SSRF/命令审批/输入审计全套安全体系 | +| 企业合规要求 | API Key/SSRF/命令审批/输入审计全套安全体系 | | 多 Agent 混合使用 | AEP v1 协议统一,切换零成本 | | 长时间运行的任务 | Session 持久化,断线不丢上下文 | diff --git a/docs/getting-started.md b/docs/getting-started.md index 1782e044..5c360213 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,12 +51,9 @@ chmod +x hotplex # macOS / Linux 赋予执行权限 cp configs/env.example .env ``` -编辑 `.env`,填入两个必填项: +编辑 `.env`,填入必填项: ```bash -# 生成命令: openssl rand -base64 32 | tr -d '\n' -HOTPLEX_JWT_SECRET= - # 生成命令: openssl rand -base64 32 | tr -d '/+=' | head -c 43 HOTPLEX_ADMIN_TOKEN_1= ``` diff --git a/docs/guides/contributor/architecture.md b/docs/guides/contributor/architecture.md index b7e6e86a..2f0c68ae 100644 --- a/docs/guides/contributor/architecture.md +++ b/docs/guides/contributor/architecture.md @@ -221,7 +221,7 @@ LLM 调用的统一编排层,提供意图分发、安全审计、上下文压 |------|------|------| | `config/` | `internal/config/` | Viper 配置 + 热重载 + 继承 + 审计/回滚 | | `agentconfig/` | `internal/agentconfig/` | B/C 双通道 Agent 人格/上下文加载器 | -| `security/` | `internal/security/` | JWT (ES256)、SSRF 防护、路径安全 | +| `security/` | `internal/security/` | API Key、Bot ID、SSRF 防护、路径安全 | | `eventstore/` | `internal/eventstore/` | 会话事件持久化 + delta 聚合 | | `metrics/` | `internal/metrics/` | Prometheus 指标 | | `service/` | `internal/service/` | 跨平台系统服务管理(systemd/launchd/SCM) | diff --git a/docs/guides/contributor/development-setup.md b/docs/guides/contributor/development-setup.md index e8a603c5..8e5950b3 100644 --- a/docs/guides/contributor/development-setup.md +++ b/docs/guides/contributor/development-setup.md @@ -86,9 +86,6 @@ cp configs/env.example .env **必填项**(开发阶段至少需要): ```bash -# JWT 签名密钥(ES256) -HOTPLEX_JWT_SECRET=$(openssl rand -base64 32 | tr -d '\n') - # Admin API Token HOTPLEX_ADMIN_TOKEN_1=$(openssl rand -base64 32 | tr -d '/+=' | head -c 43) ``` diff --git a/docs/guides/developer/security-model.md b/docs/guides/developer/security-model.md index 84fa26a7..c7b13310 100644 --- a/docs/guides/developer/security-model.md +++ b/docs/guides/developer/security-model.md @@ -1,7 +1,7 @@ --- title: 开发者安全指南 weight: 16 -description: HotPlex Gateway 安全机制配置:JWT、SSRF 防御、命令白名单与进程隔离 +description: HotPlex Gateway 安全机制配置:API Key、Bot ID、SSRF 防御、命令白名单与进程隔离 --- # 开发者安全指南 @@ -13,7 +13,7 @@ description: HotPlex Gateway 安全机制配置:JWT、SSRF 防御、命令白 HotPlex Gateway 采用纵深防御(Defense in Depth)策略,通过七层安全机制保护系统: ``` -网络层 → JWT/API Key 认证 → SSRF 防护 → 命令白名单 → 环境隔离 → Tool 控制 → 输出限制 +网络层 → API Key + Bot ID 认证 → SSRF 防护 → 命令白名单 → 环境隔离 → Tool 控制 → 输出限制 ``` 开发者需要理解每层安全机制以正确配置和使用。本文档聚焦权限请求、Tool 访问控制和安全配置最佳实践。 @@ -149,19 +149,27 @@ Cron 执行和 Session 启动时,Gateway 注入以下环境变量: 开发模式下未配置 API Key 时,所有请求以 `anonymous` 身份通过。生产环境必须配置 API Key。 -### JWT(ES256) +### API Key + Bot ID 认证 -`internal/security/jwt.go` 强制使用 ES256(ECDSA P-256)签名算法: +`internal/security/auth.go` 提供认证机制: -- 拒绝所有非 ES256 的签名方法 -- JTI 黑名单防止 Token 重放攻击 -- `bot_id` claim 实现多 Bot 隔离 +1. **API Key**:通过 `X-API-Key` Header 或 `?api_key=` Query Param 携带。`Authenticator` 在内存 `map` 中验证,支持热重载(`ReloadKeys`)。 +2. **Bot ID**:通过 `X-Bot-ID` Header 或 `bot_id` 查询参数指定 Bot 身份。每个 Bot 只能操作属于自己的 Session,**禁止跨 Bot 访问**。使用 `security.BotIDFromRequest(r)` 提取 Bot ID。 -### JTI 黑名单 +开发模式下未配置 API Key 时,所有请求以 `anonymous` 身份通过。生产环境必须配置 API Key。 + +### APIKeyResolver(多用户映射) + +通过 `security.SetKeyResolver()` 设置自定义的 `APIKeyResolver`,可将 API Key 映射到不同的 userID,实现用户级会话隔离: + +```go +security.SetKeyResolver(func(key string) (userID string, ok bool) { + // 自定义映射逻辑:查数据库、查配置等 + return resolveKeyToUser(key) +}) +``` -被撤销的 Token 通过内存黑名单(`jtiBlacklist`)追踪: -- `sync.Map` 存储 `jti → expiry` -- 后台 goroutine 每 60s 清理过期条目 +未设置 resolver 时,所有 API Key 认证的请求统一使用 `api_user` 身份。 ## 安全配置最佳实践 @@ -191,9 +199,8 @@ security: ### 3. 定期轮换密钥 -- JWT 签名密钥定期轮换 - API Key 定期更新 -- 使用 JTI 黑名单机制撤销旧 Token +- 使用编号式环境变量(`HOTPLEX_SECURITY_API_KEY_1`、`_2`)支持无损轮转 ### 4. 审计日志 diff --git a/docs/guides/developer/websocket-integration.md b/docs/guides/developer/websocket-integration.md index d85ef630..112958c9 100644 --- a/docs/guides/developer/websocket-integration.md +++ b/docs/guides/developer/websocket-integration.md @@ -182,9 +182,9 @@ curl -i --no-buffer \ } ``` -#### JWT — 多用户隔离,适合 SaaS 产品 +#### 多 Bot 隔离 -- Bot ID 路由 -签发 ES256 签名的 JWT,在 init 信封中携带: +多 Bot 场景通过 `X-Bot-ID` Header 或 `bot_id` 查询参数指定 Bot 身份,实现 Bot 级别隔离: ```json { @@ -194,31 +194,37 @@ curl -i --no-buffer \ "version": "aep/v1", "worker_type": "claude_code", "auth": { - "token": "eyJhbGciOiJFUzI1NiIs..." - } + "token": "your-api-key" + }, + "bot_id": "B12345" } } } ``` -JWT 关键 Claims: +或通过 HTTP Header 携带: + +```bash +curl -i --no-buffer \ + -H "X-API-Key: your-api-key" \ + -H "X-Bot-ID: B12345" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + http://localhost:8080/ws +``` -| 字段 | 必填 | 说明 | -| -------- | ---- | --------------------------------------------- | -| `sub` | 是 | 用户唯一标识(如 `"user-123"`),用于会话隔离 | -| `bot_id` | 否 | Bot 身份标识,用于多 Bot 隔离 | -| `exp` | 是 | 过期时间 | -| `iss` | 是 | 固定 `"hotplex"` | +对于需要区分用户身份的多用户场景,可通过 `security.SetKeyResolver()` 设置自定义的 `APIKeyResolver`,将 API Key 映射到不同的 userID,实现用户级会话隔离。 #### 如何选择 -| | API Key | JWT | -| -------- | ------------------------- | ----------------------- | -| 用户身份 | 全部为 `api_user`(共享) | `sub` claim(每人独立) | -| 会话隔离 | 无用户级隔离 | 按用户隔离 | -| 适用场景 | 单用户/内部测试 | 多用户 SaaS | +| | 仅 API Key | API Key + Bot ID | +| -------- | ---------------------------- | -------------------------- | +| 用户身份 | 全部为 `api_user`(共享) | 通过 `APIKeyResolver` 映射 | +| Bot 隔离 | 无 | 按 Bot ID 隔离 | +| 会话隔离 | 无用户级隔离 | 按 resolver 映射的 userID | +| 适用场景 | 单用户/内部测试 | 多 Bot / 多用户 SaaS | -> **多用户场景必须用 JWT**。API Key 认证下所有用户共享 `api_user` 身份,无法区分。 +> **多 Bot/多用户场景应使用 Bot ID + APIKeyResolver**。纯 API Key 认证下所有请求共享 `api_user` 身份,无法区分用户或 Bot。 --- @@ -686,18 +692,18 @@ Session ID 由四个维度派生,任何维度不同都会产生不同的 Sessi | 维度 | 说明 | 隔离效果 | | ------------------- | ------------------------------------- | ------------------- | -| **userID** | JWT `sub`(或 API Key 的 `api_user`) | 不同用户 → 不同会话 | +| **userID** | API Key Resolver 映射(或默认 `api_user`) | 不同用户 -> 不同会话 | | **workerType** | `claude_code` 等 | 不同引擎 → 不同会话 | | **clientSessionID** | 客户端生成的 ID | 不同 tab → 不同会话 | | **workDir** | 工作目录 | 不同项目 → 不同会话 | ### 多用户隔离 -使用 API Key 认证时所有用户都是 `api_user` → **无法隔离**。使用 JWT 认证,`sub` 参与派生 → **天然隔离**: +使用 API Key 认证时所有用户默认都是 `api_user`,无法隔离。配置 `APIKeyResolver` 后,不同 API Key 可映射到不同 userID,实现用户级隔离: ``` -Alice JWT {sub:"alice"} → "alice|claude_code|tab-1|/project" → Session A -Bob JWT {sub:"bob"} → "bob|claude_code|tab-1|/project" → Session B +Alice (key: ak-alice, resolver->userID: "alice") -> "alice|claude_code|tab-1|/project" -> Session A +Bob (key: ak-bob, resolver->userID: "bob") -> "bob|claude_code|tab-1|/project" -> Session B ``` `ListSessions` API 按 userID 过滤,每个用户只看到自己的会话。 @@ -898,7 +904,7 @@ WebSocket 连接建立后,**必须在 30 秒内**发送 `init` 作为第一帧 "version": "aep/v1", "worker_type": "claude_code", "auth": { - "token": "your-api-key-or-jwt" + "token": "your-api-key" }, "config": { "work_dir": "/home/user/project", @@ -970,7 +976,7 @@ WebSocket 连接建立后,**必须在 30 秒内**发送 `init` 作为第一帧 | `VERSION_MISMATCH` | 协议版本不匹配 | 检查 version 字段 | | `PROTOCOL_VIOLATION` | 协议违规 | 首帧必须是 init | | `INVALID_MESSAGE` | 消息格式错误 | 检查 JSON 结构 | -| `UNAUTHORIZED` | 认证失败 | 检查 API Key / JWT | +| `UNAUTHORIZED` | 认证失败 | 检查 API Key | | `SESSION_NOT_FOUND` | Session 不存在 | 重新 init | | `SESSION_BUSY` | Session 非活跃 | 等待或重连 | | `SESSION_EXPIRED` | 已过期 | 创建新会话 | @@ -990,7 +996,7 @@ WebSocket 连接建立后,**必须在 30 秒内**发送 `init` 作为第一帧 ### ListSessions 返回所有用户的会话? -使用 JWT 认证。API Key 认证的身份统一为 `api_user`,无法区分用户。 +配置 `APIKeyResolver`。纯 API Key 认证的身份统一为 `api_user`,无法区分用户。通过 `security.SetKeyResolver()` 设置自定义 resolver 可将 API Key 映射到不同 userID。 ### 重连后对话历史丢失? diff --git a/docs/guides/enterprise/compliance.md b/docs/guides/enterprise/compliance.md index 4bb4a303..cc7afda4 100644 --- a/docs/guides/enterprise/compliance.md +++ b/docs/guides/enterprise/compliance.md @@ -20,7 +20,7 @@ description: Security auditing, config change tracking, credential management, a hotplex check --security ``` -检查范围包括:JWT 配置完整性、TLS 状态、Admin API 暴露面、环境变量泄漏风险、Worker 命令白名单合规性。 +检查范围包括:TLS 状态、Admin API 暴露面、环境变量泄漏风险、Worker 命令白名单合规性。 ### 输入验证层级 @@ -50,7 +50,7 @@ ConfigChange{ ``` - 审计日志上限 **256 条**,超出后 FIFO 裁剪 -- 敏感字段(`security.api_keys`、`security.jwt_secret`)自动脱敏为 `[REDACTED]` +- 敏感字段(`security.api_keys`)自动脱敏为 `[REDACTED]` - 通过 `Watcher.AuditLog()` API 获取完整审计记录 ### 配置历史与回滚 @@ -73,7 +73,7 @@ curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \ | 类别 | 字段 | 生效方式 | |------|------|----------| | Hot(立即生效) | log.level, pool.*, worker.timeout, admin.tokens | fsnotify + 500ms debounce | -| Static(需重启) | gateway.addr, db.path, tls.*, jwt_secret | 仅记录,下次重启生效 | +| Static(需重启) | gateway.addr, db.path, tls.* | 仅记录,下次重启生效 | --- @@ -101,27 +101,11 @@ curl -H "Authorization: Bearer $ADMIN_TOKEN" \ | 凭证 | 环境变量 | 注入方式 | |------|----------|----------| -| JWT Secret | `HOTPLEX_JWT_SECRET` | SecretsProvider | | Admin Token | `HOTPLEX_ADMIN_TOKEN_1` / `_2` | 编号聚合 | | API Key | `HOTPLEX_SECURITY_API_KEY_1` | 编号聚合 | | Slack Token | `HOTPLEX_MESSAGING_SLACK_BOT_TOKEN` | 环境覆盖 | | Feishu Secret | `HOTPLEX_MESSAGING_FEISHU_APP_SECRET` | 环境覆盖 | -### SecretsProvider 链 - -支持多来源凭证链式查找,按顺序尝试直到找到值: - -```go -// 默认链(仅环境变量) -ChainedSecretsProvider{ - EnvSecretsProvider{}, // 从 HOTPLEX_* 环境变量读取 -} - -// 可扩展:实现 SecretsProvider 接口接入 Vault 等外部密钥管理 -// type VaultProvider struct{ ... } -// func (p *VaultProvider) Get(key string) string { ... } -``` - ### Worker 环境隔离 Worker 进程的 Environment 列表支持 `${VAR:-default}` 模板展开: @@ -130,22 +114,7 @@ Worker 进程的 Environment 列表支持 `${VAR:-default}` 模板展开: --- -## 5. JWT Token 生命周期 - -### Token 类型与 TTL - -| Token 类型 | TTL | 用途 | -|------------|-----|------| -| Access Token | 5 分钟 | 客户端短期访问凭证 | -| Gateway Token | 1 小时 | WebSocket 长连接认证 | -| Refresh Token | 7 天 | Token 续期 | - -### 安全机制 - -- **ES256 签名**:强制 ECDSA P-256,拒绝其他算法 -- **JTI 防重放**:`crypto/rand` 生成唯一 ID + 内存黑名单 TTL 缓存 -- **Bot 隔离**:`bot_id` claim 精确匹配,跨 Bot 操作被拒绝 -- **密钥长度**:JWT Secret 必须 >= 32 字节(base64 编码) +## 5. Admin Token 安全管理 ### Admin Token 双 Token 轮转模式 @@ -192,7 +161,6 @@ EOF ## 7. 合规检查清单 -- [ ] JWT Secret >= 32 字节,仅通过环境变量注入 - [ ] Admin API 启用 IP 白名单 + Rate Limit - [ ] 非本地地址启用 TLS (`tls_enabled: true`) - [ ] 敏感凭证不在 config.yaml 中明文存储 diff --git a/docs/guides/enterprise/config-management.md b/docs/guides/enterprise/config-management.md index 82e34a17..f089324d 100644 --- a/docs/guides/enterprise/config-management.md +++ b/docs/guides/enterprise/config-management.md @@ -88,7 +88,6 @@ config-a.yaml → config-b.yaml → config-a.yaml | `gateway.addr` | 端口绑定 | | `db.path` | 数据库连接 | | `tls_enabled` / `tls_cert_file` / `tls_key_file` | TLS 配置 | -| `security.jwt_secret` | 签名密钥 | | `log.format` | 日志格式 | ### 实现原理 diff --git a/docs/guides/enterprise/deployment.md b/docs/guides/enterprise/deployment.md index d40495f3..41a03262 100644 --- a/docs/guides/enterprise/deployment.md +++ b/docs/guides/enterprise/deployment.md @@ -23,7 +23,7 @@ description: Production deployment, security hardening, and operational best pra ```bash make build -cp configs/env.example .env # 至少设置 HOTPLEX_JWT_SECRET、HOTPLEX_ADMIN_TOKEN_1 +cp configs/env.example .env # 至少设置 HOTPLEX_SECURITY_API_KEY_1、HOTPLEX_ADMIN_TOKEN_1 ./hotplex gateway start # 注册为系统服务(Linux/macOS/Windows) @@ -36,7 +36,7 @@ hotplex service install --level system # 系统级(需 sudo) ```bash docker run -d --name hotplex --init --restart unless-stopped \ -p 8888:8888 -p 9999:9999 \ - -e HOTPLEX_JWT_SECRET="${JWT_SECRET}" \ + -e HOTPLEX_SECURITY_API_KEY_1="${API_KEY}" \ -e HOTPLEX_ADMIN_TOKEN_1="${ADMIN_TOKEN}" \ -v hotplex-data:/var/lib/hotplex/data \ -v hotplex-logs:/var/log/hotplex \ @@ -77,12 +77,16 @@ security: allowed_origins: ["https://app.yourdomain.com"] ``` -### 2.2 JWT 认证 +### 2.2 API Key + Bot ID 认证 -ES256(ECDSA P-256)签名,**禁止 HS256**。JWT 包含 `sub`(用户)、`bot_id`(Bot 隔离)、`scopes`(权限列表),Gateway 验证 `aud: "hotplex-gateway"`。 +请求通过 `X-API-Key` Header 或 `?api_key=` Query Param 携带密钥。Bot ID 通过 `X-Bot-ID` Header 或 `bot_id` 查询参数指定。 ```bash -export HOTPLEX_JWT_SECRET="$(openssl rand -base64 32 | tr -d '\n')" # 不要写入配置文件 +# API Key 认证(编号式环境变量支持无损轮转) +HOTPLEX_SECURITY_API_KEY_1=ak-xxxxx # 请求头:X-API-Key: ak-xxxxx + +# Bot ID 指定(多 Bot 隔离) +X-Bot-ID: your-bot-id ``` ### 2.3 API Key + Admin Token @@ -172,7 +176,7 @@ Docker 需同时配置容器级限制(`deploy.resources.limits: cpus 4, memory ### 路由 + 工作目录隔离 -JWT `bot_id` claim 实现 session 路由隔离。Per-bot `work_dir` 实现文件系统隔离: +`X-Bot-ID` Header 实现 session 路由隔离。Per-bot `work_dir` 实现文件系统隔离: ```yaml messaging: diff --git a/docs/guides/enterprise/disaster-recovery.md b/docs/guides/enterprise/disaster-recovery.md index 1e040d62..88d346b3 100644 --- a/docs/guides/enterprise/disaster-recovery.md +++ b/docs/guides/enterprise/disaster-recovery.md @@ -181,23 +181,6 @@ curl http://localhost:9999/admin/health ## 5. 密钥轮转流程 -### JWT Secret 轮转 - -```bash -# 1. 生成新密钥 -NEW_SECRET=$(openssl rand -base64 32) - -# 2. 更新环境配置 -# 编辑 /etc/hotplex/.env 或 secrets.env -# HOTPLEX_JWT_SECRET="$NEW_SECRET" - -# 3. 重启服务 -systemctl restart hotplex - -# 4. 通知所有客户端更新 Token -# 注意:旧 Token 将立即失效 -``` - ### Admin Token 轮转(零停机) ```bash diff --git a/docs/guides/enterprise/integration-patterns.md b/docs/guides/enterprise/integration-patterns.md index 9730817f..98e5ed24 100644 --- a/docs/guides/enterprise/integration-patterns.md +++ b/docs/guides/enterprise/integration-patterns.md @@ -234,7 +234,8 @@ worker: import "github.com/hrygo/hotplex/client" client := client.New("ws://localhost:8888/ws", - client.WithToken("your-jwt-token"), + client.APIKey("your-api-key"), + client.BotID("your-bot-id"), ) // 创建 Session @@ -258,7 +259,7 @@ for event := range session.Events() { import { HotPlexClient } from '@hotplex/sdk'; const client = new HotPlexClient('ws://localhost:8888/ws', { - token: 'your-jwt-token', + apiKey: 'your-api-key', }); const session = await client.createSession({ @@ -276,7 +277,7 @@ await session.input('分析这个代码库的性能瓶颈'); ```python from hotplex import HotPlexClient -client = HotPlexClient("ws://localhost:8888/ws", token="your-jwt-token") +client = HotPlexClient("ws://localhost:8888/ws", api_key="your-api-key") session = client.create_session(worker_type="claude_code", work_dir="/workspace/project") for event in session.stream("分析这个代码库的性能瓶颈"): diff --git a/docs/guides/enterprise/multi-tenant.md b/docs/guides/enterprise/multi-tenant.md index 12307f18..98e8ba96 100644 --- a/docs/guides/enterprise/multi-tenant.md +++ b/docs/guides/enterprise/multi-tenant.md @@ -6,7 +6,7 @@ description: Per-bot isolation, access control, and resource quotas for multi-te # Multi-Tenant Isolation Guide -> 面向企业多团队/多 Bot 场景的 HotPlex 租户隔离方案。涵盖 Agent 配置隔离、JWT 路由、Session 配额和访问控制策略。 +> 面向企业多团队/多 Bot 场景的 HotPlex 租户隔离方案。涵盖 Agent 配置隔离、Bot ID 路由、Session 配额和访问控制策略。 --- @@ -17,7 +17,7 @@ HotPlex 采用 **Bot-centric 隔离模型**:每个 Bot(Slack Bot / Feishu Ap | 隔离层 | 机制 | 范围 | |--------|------|------| | 配置隔离 | 3-level Agent Config fallback | Bot 级别 | -| 路由隔离 | JWT `bot_id` claim | 请求级别 | +| 路由隔离 | `X-Bot-ID` Header | 请求级别 | | 工作目录隔离 | Per-bot `work_dir` | 进程级别 | | 资源隔离 | Per-user Session/Memory 配额 | 用户级别 | | 访问控制 | DM/Group policy + allowlist | 平台级别 | @@ -56,25 +56,26 @@ agent-configs/ --- -## 3. JWT bot_id 路由隔离 +## 3. Bot ID 路由隔离 -JWT Token 包含 `bot_id` claim,网关在请求处理时强制校验: +通过 `X-Bot-ID` Header 或 `bot_id` 查询参数指定 Bot 身份,网关在请求处理时强制校验: ``` -JWT Claims: - iss: "hotplex" - sub: "user_id" - bot_id: "U12345" ← 路由隔离关键字段 - user_id: "U12345" - session_id: "..." - role: "user" - scopes: ["session:read", "session:write"] +请求 Header: + X-API-Key: your-api-key + X-Bot-ID: U12345 ← 路由隔离关键字段 +``` + +或通过查询参数: + +``` +ws://localhost:8888/ws?api_key=your-api-key&bot_id=U12345 ``` **隔离规则**: - `bot_id` 必须与 Session 所属 Bot **精确匹配** - 跨 Bot 操作被硬拒绝,返回 `403 Forbidden` -- 每个 Bot 独立的 ES256 密钥对,共享 JWT Secret 但 bot_id 互斥 +- 使用 `security.BotIDFromRequest(r)` 提取 Bot ID --- diff --git a/docs/guides/enterprise/security-hardening.md b/docs/guides/enterprise/security-hardening.md index 95857957..8df3d79c 100644 --- a/docs/guides/enterprise/security-hardening.md +++ b/docs/guides/enterprise/security-hardening.md @@ -1,7 +1,7 @@ --- title: 企业安全加固指南 weight: 22 -description: HotPlex Gateway 生产安全加固全流程:JWT 认证、SSRF 防御、命令白名单、网络隔离与合规审计 +description: HotPlex Gateway 生产安全加固全流程:API Key、Bot ID、SSRF 防御、命令白名单、网络隔离与合规审计 --- # Security Hardening 企业安全加固指南 @@ -47,41 +47,13 @@ location /ws { **零密钥 = 开发模式**:未配置 API Key 时自动降级为 `anonymous` 用户,**生产环境必须配置至少一个 Key**。 -### 2.2 JWT ES256 Token +### 2.2 Bot ID 隔离 -仅接受 **ES256**(ECDSA P-256)签名算法,拒绝其他所有算法: +通过 `X-Bot-ID` Header 或 `bot_id` 查询参数指定 Bot 身份。每个 Bot 只能操作属于自己的 Session,**禁止跨 Bot 访问**。使用 `security.BotIDFromRequest(r)` 提取 Bot ID。 -```go -// 算法白名单,仅 ES256 -switch token.Method.Alg() { -case "ES256": - // 验证签名 -default: - return fmt.Errorf("rejected signing method: %v (only ES256)", alg) -} -``` - -**JWT Claims 结构**(RFC 7519 + HotPlex 扩展): - -| 字段 | 类型 | 说明 | -|------|------|------| -| `iss` | string | 固定 `hotplex` | -| `sub` | string | 用户 ID | -| `aud` | string | 受众校验 | -| `exp` / `iat` / `nbf` | timestamp | 生命周期 | -| `jti` | UUID | 防重放,支持黑名单撤销 | -| `user_id` | string | 用户标识 | -| `bot_id` | string | Bot 隔离 ID | -| `scopes` | []string | 权限范围 | -| `role` | string | 角色 | - -### 2.3 Bot ID 隔离 - -JWT 中 `bot_id` Claim 经过签名验证后提取。每个 Bot 只能操作属于自己的 Session,**禁止跨 Bot 访问**。即使 API Key 相同,不同 `bot_id` 的请求也被严格隔离。 - -### 2.4 Token 撤销 +### 2.3 APIKeyResolver(多用户映射) -JTI 黑名单机制:每个 Token 的 `jti` 可被加入内存黑名单,后台每分钟自动清理过期条目。支持 `RevokeToken(jti, ttl)` 单 Token 撤销。 +通过 `security.SetKeyResolver()` 设置自定义的 `APIKeyResolver`,可将 API Key 映射到不同的 userID,实现用户级会话隔离。未设置 resolver 时,所有 API Key 认证的请求统一使用 `api_user` 身份。 --- @@ -147,7 +119,7 @@ allowedCommands = map[string]bool{ ``` HOTPLEX_WORKER_GITHUB_TOKEN=xxx → GITHUB_TOKEN=xxx(Worker 环境可见) -HOTPLEX_JWT_SECRET=yyy → 完全不可见(Gateway 内部变量) +HOTPLEX_GATEWAY_TOKEN=yyy → 完全不可见(Gateway 内部变量) ``` 当剥离后的 Key 与系统变量冲突时,系统版本被**动态阻断**,防止 Gateway 自身密钥泄漏到 Worker。 @@ -202,14 +174,13 @@ Risky / Network / System 类工具在开发模式下可用,但 Bash 命令受 |---|--------|------| | 1 | Gateway 绑定 localhost,未暴露公网 | ☐ | | 2 | 至少配置一个 API Key(生产环境) | ☐ | -| 3 | JWT 使用 ES256 签名 | ☐ | -| 4 | `bot_id` 隔离验证生效 | ☐ | -| 5 | SSRF BlockedCIDRs 覆盖私有/元数据地址 | ☐ | -| 6 | Worker 命令白名单仅含 claude/opencode | ☐ | -| 7 | `HOTPLEX_WORKER_` 前缀隔离正确配置 | ☐ | -| 8 | 生产环境使用 `ProductionAllowedTools`(3 工具) | ☐ | -| 9 | Output Limits 未被修改 | ☐ | -| 10 | TLS 由反向代理终止 | ☐ | +| 3 | Bot ID 隔离验证生效(Header + Query) | ☐ | +| 4 | SSRF BlockedCIDRs 覆盖私有/元数据地址 | ☐ | +| 5 | Worker 命令白名单仅含 claude/opencode | ☐ | +| 6 | `HOTPLEX_WORKER_` 前缀隔离正确配置 | ☐ | +| 7 | 生产环境使用 `ProductionAllowedTools`(3 工具) | ☐ | +| 8 | Output Limits 未被修改 | ☐ | +| 9 | TLS 由反向代理终止 | ☐ | --- @@ -217,8 +188,7 @@ Risky / Network / System 类工具在开发模式下可用,但 Bash 命令受 | 模块 | 文件 | |------|------| -| API Key + JWT 认证 | `internal/security/auth.go` | -| JWT ES256 验证 | `internal/security/jwt.go` | +| API Key 认证 + Bot ID | `internal/security/auth.go` | | SSRF 4 层防护 | `internal/security/ssrf.go` | | 命令白名单 + Bash 策略 | `internal/security/command.go` | | 环境变量隔离 | `internal/security/env.go` | diff --git a/docs/index.md b/docs/index.md index 1f053b55..3dfb4482 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,7 +67,7 @@ HotPlex 是一个 AI Coding Agent 统一管理平台。通过飞书、Slack 或 | [企业部署](guides/enterprise/deployment.md) | 生产环境部署、安全加固、资源管理 | | [安全加固](guides/enterprise/security-hardening.md) | 7 层安全体系详解 | | [可观测性](guides/enterprise/observability.md) | 日志、Prometheus、OpenTelemetry、告警 | -| [多租户隔离](guides/enterprise/multi-tenant.md) | Bot 级隔离、JWT 路由、会话配额 | +| [多租户隔离](guides/enterprise/multi-tenant.md) | Bot 级隔离、Bot ID 路由、会话配额 | | [合规与审计](guides/enterprise/compliance.md) | 配置审计、凭据管理、回滚能力 | | [灾备恢复](guides/enterprise/disaster-recovery.md) | RTO/RPO、自动重启、备份策略 | | [配置管理](guides/enterprise/config-management.md) | 5 层优先级、热重载、多环境策略 | @@ -97,7 +97,7 @@ HotPlex 是一个 AI Coding Agent 统一管理平台。通过飞书、Slack 或 | [Admin API 参考](reference/admin-api.md) | 管理端点、Scope 权限、请求/响应格式 | | [AEP 协议参考](reference/aep-protocol.md) | Agent Exchange Protocol v1 完整规范 | | [事件参考](reference/events.md) | 全部 AEP 事件类型和数据结构 | -| [安全策略参考](reference/security-policies.md) | JWT、SSRF、命令白名单、工具控制 | +| [安全策略参考](reference/security-policies.md) | API Key、Bot ID、SSRF、命令白名单、工具控制 | | [Metrics 参考](reference/metrics.md) | Prometheus 指标、scrape 配置 | | [术语表](reference/glossary.md) | HotPlex 核心术语解释 | | [Go SDK 参考](reference/sdk-go.md) | Go 客户端 SDK API 文档 | diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md index ce1af640..e9791231 100644 --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -219,17 +219,17 @@ Bot 状态查询、配置管理和 Agent 配置文件操作端点。 ## Gateway API 端点 -Gateway API(`/api/sessions`)监听在网关主端口(`8888`),面向客户端 SDK 和 WebSocket 连接,使用 API Key 或 JWT 认证(非 Bearer Token)。 +Gateway API(`/api/sessions`)监听在网关主端口(`8888`),面向客户端 SDK 和 WebSocket 连接,使用 API Key 认证(非 Bearer Token)。 | 方法 | 路径 | 认证 | 说明 | |------|------|------|------| -| GET | `/api/sessions` | API Key / JWT | 列出当前用户的会话 | -| POST | `/api/sessions` | API Key / JWT | 创建会话 | -| GET | `/api/sessions/{id}` | API Key / JWT | 获取单个会话 | -| DELETE | `/api/sessions/{id}` | API Key / JWT | 删除会话 | -| POST | `/api/sessions/{id}/cd` | API Key / JWT | 切换工作目录 | -| GET | `/api/sessions/{id}/history` | API Key / JWT | 获取会话历史 | -| GET | `/api/sessions/{id}/events` | API Key / JWT | 获取会话事件流 | +| GET | `/api/sessions` | API Key | 列出当前用户的会话 | +| POST | `/api/sessions` | API Key | 创建会话 | +| GET | `/api/sessions/{id}` | API Key | 获取单个会话 | +| DELETE | `/api/sessions/{id}` | API Key | 删除会话 | +| POST | `/api/sessions/{id}/cd` | API Key | 切换工作目录 | +| GET | `/api/sessions/{id}/history` | API Key | 获取会话历史 | +| GET | `/api/sessions/{id}/events` | API Key | 获取会话事件流 | 所有 Gateway API 端点启用 CORS(`Access-Control-Allow-Origin: *`),支持 `GET`、`POST`、`DELETE`、`OPTIONS` 方法。 diff --git a/docs/reference/aep-protocol.md b/docs/reference/aep-protocol.md index c5da5d81..047beffd 100644 --- a/docs/reference/aep-protocol.md +++ b/docs/reference/aep-protocol.md @@ -71,7 +71,7 @@ WebSocket 连接建立后的**第一帧**必须是 `init`,30 秒超时。 "version": "aep/v1", "worker_type": "claude_code", "session_id": "sess_xxx", - "auth": { "token": "" }, + "auth": { "token": "" }, "config": { "model": "claude-sonnet-4-6", "allowed_tools": ["read_file", "write_file"], diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 82e9ab68..ed707f5f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -220,7 +220,7 @@ hotplex doctor --json # JSON 输出(用于脚本集成) ### `hotplex security` -对 HotPlex 配置运行安全审计。检查 TLS 设置、SSRF 防护、JWT 配置和访问策略。 +对 HotPlex 配置运行安全审计。检查 TLS 设置、SSRF 防护和访问策略。 **示例**: @@ -251,13 +251,11 @@ hotplex security --json # JSON 输出 ```bash hotplex config validate # 验证默认配置 hotplex config validate -c /path/to/config.yaml -hotplex config validate --strict # 同时检查密钥配置 ``` | 标志 | 短标志 | 类型 | 默认值 | 说明 | |------|--------|------|--------|------| | `--config` | `-c` | `string` | `~/.hotplex/config.yaml` | 配置文件路径 | -| `--strict` | | `bool` | `false` | 严格模式,同时验证必需的密钥(JWT、Admin Token 等)是否已设置 | --- diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0d07c3dc..874e52a1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -46,15 +46,13 @@ HotPlex 采用分层覆盖策略,**高优先级覆盖低优先级**: 配置文件 (YAML/JSON/TOML) ↓ 被覆盖 环境变量 (HOTPLEX_*) - ↓ 被覆盖 -Secrets Provider (JWT 等敏感字段,仅通过 Provider 加载) ``` **要点**: - 代码默认值让二进制可以在零配置下启动(敏感字段除外) - 配置文件是**非敏感值的权威来源** -- 敏感字段(JWT secret、Admin tokens、API keys)**永远不会从配置文件加载**,只能通过环境变量或 Secrets Provider 注入 +- 敏感字段(Admin tokens、API keys)**永远不会从配置文件加载**,只能通过环境变量或 Secrets Provider 注入 - 消息平台配置有额外的三级优先级:`platform-level > messaging-level > Default()` --- @@ -172,8 +170,6 @@ SQLite 数据库配置,Session 和 Event Store 共享。 | `tls_cert_file` | string | `/etc/hotplex/tls/server.crt` | — | TLS 证书文件路径 | | `tls_key_file` | string | `/etc/hotplex/tls/server.key` | — | TLS 私钥文件路径 | | `allowed_origins` | []string | `["*"]` | — | WebSocket CORS 允许的 Origin 列表 | -| `jwt_secret` | []byte | — | `HOTPLEX_JWT_SECRET` | JWT 签名密钥(ES256)。**仅通过环境变量注入**,支持 raw 32 字节或 base64 编码。配置文件中不可设置 | -| `jwt_audience` | string | `hotplex-gateway` | `HOTPLEX_SECURITY_JWT_AUDIENCE` | JWT audience 声明 | | `work_dir_allowed_base_patterns` | []string | `[]` | — | 额外的工作目录白名单模式。支持 `~` 和 `${VAR}` 展开。程序内建默认值:`~/.hotplex/workspace`、`~/workspace`、`~/projects`、`~/work`、`~/dev`、`/var/hotplex/projects` | | `work_dir_forbidden_dirs` | []string | `[]` | — | 额外的工作目录黑名单。显式禁止的目录列表 | @@ -537,7 +533,6 @@ HotPlex 通过 `fsnotify` 监听配置文件变更,支持运行时热更新。 | `security.tls_enabled` | TLS 开关 | | `security.tls_cert_file` | TLS 证书路径 | | `security.tls_key_file` | TLS 私钥路径 | -| `security.jwt_secret` | JWT 密钥 | | `db.path` | 数据库路径 | | `db.wal_mode` | WAL 模式 | @@ -573,7 +568,7 @@ HOTPLEX_SECURITY_API_KEY_1, HOTPLEX_SECURITY_API_KEY_2, ... | 变量 | 说明 | 示例 | |------|------|------| -| `HOTPLEX_JWT_SECRET` | JWT 签名密钥(ES256,≥32 字节) | `openssl rand -base64 32 \| tr -d '\n'` | +| `HOTPLEX_SECURITY_API_KEY_1` | API Key(至少配置一个) | `openssl rand -base64 32 \| tr -d '/+=' \| head -c 43` | | `HOTPLEX_ADMIN_TOKEN_1` | Admin API 认证 token | `openssl rand -base64 32 \| tr -d '/+=' \| head -c 43` | ### 5.3 完整环境变量列表 @@ -623,11 +618,9 @@ HOTPLEX_SECURITY_API_KEY_1, HOTPLEX_SECURITY_API_KEY_2, ... | 变量 | 对应配置 | 说明 | |------|----------|------| -| `HOTPLEX_JWT_SECRET` | `security.jwt_secret` | 仅通过此变量注入 | | `HOTPLEX_ADMIN_TOKEN_1..N` | `admin.tokens` | 编号后缀,支持轮换 | | `HOTPLEX_SECURITY_API_KEY_1..N` | `security.api_keys` | 编号后缀,支持轮换 | | `HOTPLEX_SECURITY_API_KEY_HEADER` | `security.api_key_header` | 默认 `X-API-Key` | -| `HOTPLEX_SECURITY_JWT_AUDIENCE` | `security.jwt_audience` | 默认 `hotplex-gateway` | #### Agent Config diff --git a/docs/reference/sdk-go.md b/docs/reference/sdk-go.md index 42ab2d15..209bfa49 100644 --- a/docs/reference/sdk-go.md +++ b/docs/reference/sdk-go.md @@ -16,7 +16,7 @@ description: 基于 AEP v1 协议的 HotPlex Worker Gateway Go 客户端 SDK 完 go get github.com/hrygo/hotplex/client ``` -依赖:`gorilla/websocket`(WebSocket)、`golang-jwt/jwt/v5`(Token 生成)。 +依赖:`gorilla/websocket`(WebSocket)。 ## 快速开始 @@ -90,7 +90,7 @@ func main() { c, err := client.New(ctx, client.URL("ws://localhost:8888"), // 必填:Gateway WebSocket 地址 client.WorkerType("claude_code"), // 必填:Worker 类型 - client.AuthToken("eyJ..."), // JWT Bearer token(可选) + client.BotID("bot-123"), // Bot ID for multi-bot setups client.APIKey("ak-xxx"), // X-API-Key header(可选) client.AutoReconnect(true), // 启用指数退避自动重连 client.PingInterval(54*time.Second), // 心跳间隔(默认 54s) @@ -285,27 +285,19 @@ for evt := range ch { } ``` -## Token 生成 +## Bot ID(多 Bot 设置) -SDK 内置 `TokenGenerator`,用于生成 ES256 JWT 认证令牌: +在多 Bot 环境中,使用 `BotID` 选项指定目标 Bot: ```go -tg, err := client.NewTokenGenerator("path/to/key.pem") -if err != nil { - log.Fatal(err) -} - -tg.WithBotID("B12345").WithAudience("gateway") - -token, err := tg.Generate( - "user-1", // subject - []string{"session:create", "session:write"}, // scopes - 1*time.Hour, // TTL +c, err := client.New(ctx, + client.URL("ws://localhost:8888"), + client.WorkerType("claude_code"), + client.APIKey("ak-xxx"), + client.BotID("bot-123"), // 指定 Bot ID ) ``` -支持三种密钥格式:PEM 文件路径、64 位 hex 字符串、44 位 base64 字符串。 - ## 完整示例 ### 权限处理 diff --git a/docs/reference/sdk-java.md b/docs/reference/sdk-java.md index 8190a570..979530c5 100644 --- a/docs/reference/sdk-java.md +++ b/docs/reference/sdk-java.md @@ -31,8 +31,6 @@ mvn clean install -DskipTests |------|------|------| | `spring-boot-starter-websocket` | 3.2.5 | WebSocket 客户端 | | `jackson-databind` + `jackson-datatype-jsr310` | (managed) | JSON 序列化 | -| `jjwt-api` / `jjwt-impl` / `jjwt-jackson` | 0.12.6 | JWT 生成 | -| `bcprov-jdk18on` | 1.78 | BouncyCastle ECDSA | ## 快速开始 @@ -41,20 +39,18 @@ mvn clean install -DskipTests ```java import dev.hotplex.client.HotPlexClient; import dev.hotplex.protocol.*; -import dev.hotplex.security.JwtTokenGenerator; public class QuickStart { public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("HOTPLEX_GATEWAY_URL", "ws://localhost:8888"); - String signingKey = System.getenv("HOTPLEX_SIGNING_KEY"); // 至少 32 字节 + String apiKey = System.getenv("HOTPLEX_API_KEY"); - var tokenGen = new JwtTokenGenerator(signingKey, "hotplex"); var latch = new java.util.concurrent.CountDownLatch(1); try (var client = HotPlexClient.builder() .url(url) .workerType("claude-code") - .tokenGenerator(tokenGen) + .apiKey(apiKey) .build()) { client.on("messageDelta", delta -> { @@ -81,7 +77,7 @@ public class QuickStart { ```java try (var client = HotPlexClient.builder() - .url(url).workerType("claude-code").tokenGenerator(tokenGen).build()) { + .url(url).workerType("claude-code").apiKey(apiKey).build()) { client.on("messageDelta", d -> { System.out.print(d.getContent()); @@ -117,7 +113,6 @@ var client = HotPlexClient.builder() .url("ws://localhost:8888") // 必填:Gateway WebSocket 地址 .workerType("claude-code") // 必填:Worker 类型 .apiKey("ak-xxx") // 可选:X-API-Key header - .tokenGenerator(jwtGenerator) // 可选:JWT ES256 认证 .config(initConfig) // 可选:Session 配置 .build(); ``` @@ -279,45 +274,6 @@ public enum SessionState { 收到 `SESSION_BUSY` 错误时,自动延迟 2 秒(`SESSION_BUSY_RETRY_DELAY_MS`)后重发待处理输入。 -## Token 生成 - -### JwtTokenGenerator - -ES256 (ECDSA P-256) 签名,从 `secret` 派生 P-256 密钥对: - -```java -// 基本用法 -var tokenGen = new JwtTokenGenerator(secret, "hotplex"); - -// 自定义 audience -var tokenGen = new JwtTokenGenerator(secret, "hotplex", "my-gateway"); -``` - -- `secret` — 至少 32 字节的签名字符串 -- 内部通过 BouncyCastle HKDF-SHA256 从 secret 派生 P-256 密钥对,与 Go SDK 完全一致 - -```java -// 生成 token -String token = tokenGen.generateToken( - "user-1", // subject - List.of("session:create", "session:write"), // scopes - 3600L // TTL 秒 -); - -// 带自定义 JTI -String token = tokenGen.generateTokenWithJti( - "user-1", List.of("worker:use"), 3600L, "my-trace-id" -); - -// 带额外 claims -String token = tokenGen.generateTokenWithClaims( - "user-1", List.of("worker:use"), 3600L, - Map.of("bot_id", "B12345") -); -``` - -JWT Claims:`sub`(subject)、`iss`(issuer)、`aud`(audience)、`iat`(签发时间)、`exp`(过期时间)、`scopes`(权限列表)、`jti`(唯一 ID)。 - ## 已知限制 1. **无自定义异常层次**:连接/发送错误通过 `RuntimeException` 或 `CompletableFuture` 异常传递,无 `HotPlexException` 等自定义类型。 diff --git a/docs/reference/sdk-python.md b/docs/reference/sdk-python.md index e3b1a330..dfd835df 100644 --- a/docs/reference/sdk-python.md +++ b/docs/reference/sdk-python.md @@ -56,7 +56,7 @@ from hotplex_client import HotPlexClient, WorkerType client = HotPlexClient( url="ws://localhost:8888", # 必填:Gateway WebSocket 地址 worker_type=WorkerType.CLAUDE_CODE, # 必填:Worker 类型 - auth_token="eyJ...", # JWT 认证(可选) + auth_token="your-api-key", # API Key 认证(可选) session_id="sess_xxxx", # 恢复已有 session(可选) config={"model": "sonnet"}, # Worker 配置覆盖(可选) ) @@ -225,7 +225,7 @@ session_id = await transport.connect( url="ws://localhost:8888", worker_type=WorkerType.CLAUDE_CODE, session_id=None, # 恢复已有 session - auth_token=None, # JWT 认证 + auth_token=None, # API Key 认证 config=None, # Worker 配置 ) @@ -297,7 +297,7 @@ try: async with HotPlexClient( url="ws://localhost:8888", worker_type=WorkerType.CLAUDE_CODE, - auth_token="your-jwt-token", + auth_token="your-api-key", ) as client: await client.send_input("hello") result = await client.wait_for_done(timeout=120) diff --git a/docs/reference/sdk-typescript.md b/docs/reference/sdk-typescript.md index b5c65660..304be77f 100644 --- a/docs/reference/sdk-typescript.md +++ b/docs/reference/sdk-typescript.md @@ -17,7 +17,7 @@ npm install hotplex-client # 或从 examples/typescript-client/ 本地引用 ``` -依赖:`ws`(WebSocket 客户端)、`eventemitter3`(类型安全事件分发)、`jose`(JWT 工具)。 +依赖:`ws`(WebSocket 客户端)、`eventemitter3`(类型安全事件分发)。 ## 快速开始 @@ -61,7 +61,7 @@ const client = new HotPlexClient({ url: 'ws://localhost:8888/ws', // 必填:Gateway WebSocket 地址 workerType: WorkerType.ClaudeCode, // 必填:Worker 类型 apiKey: 'ak-xxx', // X-API-Key header(可选) - authToken: 'eyJ...', // JWT Bearer token(可选) + authToken: 'your-api-key', // 延迟浏览器认证(可选) reconnect: { enabled: true, // 启用自动重连(默认 true) maxAttempts: 10, // 最大重连次数(默认 10) diff --git a/docs/reference/security-policies.md b/docs/reference/security-policies.md index 0adecf55..1e9e44c5 100644 --- a/docs/reference/security-policies.md +++ b/docs/reference/security-policies.md @@ -1,7 +1,7 @@ --- title: "安全策略参考" weight: 9 -description: "JWT、SSRF、命令白名单、Tool 控制、API Key 等安全配置完整参考" +description: "API Key、SSRF、命令白名单、Tool 控制等安全配置完整参考" --- # 安全策略参考 @@ -12,81 +12,9 @@ description: "JWT、SSRF、命令白名单、Tool 控制、API Key 等安全配 HotPlex Gateway 的安全策略分布在多个配置层:环境变量、`config.yaml`、SQLite 持久化配置。本文档按安全域组织所有配置项。 -## JWT 配置 +## API Key 认证 -### 环境变量 - -| 变量 | 必填 | 说明 | -|------|------|------| -| `HOTPLEX_JWT_SECRET` | 是 | JWT 签名密钥。使用 `openssl rand -base64 32` 生成 | - -### 签名算法 - -ES256(ECDSA P-256)是唯一允许的签名算法。源码实现位于 `internal/security/jwt.go`: - -```go -// 拒绝所有非 ES256 的签名方法 -switch token.Method.Alg() { -case "ES256": - // 唯一允许的算法 -default: - return nil, fmt.Errorf("rejected signing method: %v", token.Header["alg"]) -} -``` - -### Claims 结构 - -| 字段 | JSON Key | 类型 | 说明 | -|------|----------|------|------| -| Issuer | `iss` | string | 固定值 `hotplex` | -| Subject | `sub` | string | 用户 ID | -| Audience | `aud` | string | 受众(可配置校验) | -| ExpiresAt | `exp` | timestamp | 过期时间 | -| IssuedAt | `iat` | timestamp | 签发时间 | -| NotBefore | `nbf` | timestamp | 生效时间 | -| ID | `jti` | string | 唯一 ID(UUID v4),用于撤销检测 | -| UserID | `user_id` | string | 用户标识 | -| Scopes | `scopes` | []string | 权限范围 | -| Role | `role` | string | 角色 | -| BotID | `bot_id` | string | Bot 标识 | -| SessionID | `session_id` | string | Session 标识 | - -### 密钥派生(HKDF) - -当配置为 `[]byte`(原始密钥)时,通过 HKDF (RFC 5869) 从字节派生 ECDSA P-256 密钥。info 参数 `"hotplex-ecdsa-p256"` 将派生密钥绑定到特定上下文,防止跨协议密钥复用: - -```go -func deriveECDSAP256Key(secret []byte) *ecdsa.PrivateKey { - // HKDF-SHA256 extract-then-expand - scalarBytes, _ := hkdf.Key(sha256.New, secret, nil, "hotplex-ecdsa-p256", 32) - s := new(big.Int).SetBytes(scalarBytes) - N := elliptic.P256().Params().N - s.Mod(s, new(big.Int).Sub(N, big.NewInt(1))) - s.Add(s, big.NewInt(1)) // scalar ∈ [1, N-1] - x, y := elliptic.P256().ScalarBaseMult(s.Bytes()) - return &ecdsa.PrivateKey{...} -} -``` - -> **升级注意**:v1.11.3 从 `copy(secret)` 直接截断改为 HKDF。同一个 `HOTPLEX_JWT_SECRET` 会派生出不同的 ECDSA 密钥对,所有旧 token 在升级后立即失效。Go Client SDK 已同步更新。 - -### JTI 黑名单 - -| 参数 | 值 | 说明 | -|------|------|------| -| 存储 | `sync.Map` | 并发安全 | -| 清理间隔 | 60s | 后台 goroutine | -| TTL | Token TTL × 2 | 默认为 Token 过期时间的 2 倍 | - -### Token 生命周期 - -| 类型 | 推荐 TTL | -|------|---------| -| Access Token | 5 分钟 | -| Gateway Token | 1 小时 | -| Refresh Token | 7 天 | - -## API Key 配置 +API Key 通过 `HOTPLEX_SECURITY_API_KEY_1..N` 环境变量设置。为空时进入 dev mode(允许所有请求)。 ### 环境变量 diff --git a/docs/security/Env-Whitelist-Strategy.md b/docs/security/Env-Whitelist-Strategy.md index 75a65987..9b8d9064 100644 --- a/docs/security/Env-Whitelist-Strategy.md +++ b/docs/security/Env-Whitelist-Strategy.md @@ -18,9 +18,8 @@ | 变量模式 | 风险 | 严重度 | |----------|------|--------| -| `HOTPLEX_JWT_SECRET` | JWT 签名密钥 | P0 | | `HOTPLEX_ADMIN_TOKEN_*` | 管理员 Token | P0 | -| `HOTPLEX_GATEWAY_TOKEN` | 网关认证 Token | P0 | +| `HOTPLEX_SECURITY_API_KEY_*` | API Key | P0 | | `HOTPLEX_SLACK_*` | Slack App 凭证 | P1 | | `HOTPLEX_FEISHU_*` | 飞书 App 凭证 | P1 | | `CLAUDECODE` | 嵌套 Agent 注入 | P1 | @@ -98,7 +97,7 @@ var openCodeSrvEnvBlocklist = []string{ | 条目 | 匹配规则 | 示例 | |------|---------|------| | `"CLAUDECODE"` | 精确匹配 | 仅阻止 `CLAUDECODE` | -| `"HOTPLEX_"` | 前缀匹配 | 阻止 `HOTPLEX_JWT_SECRET`、`HOTPLEX_ADMIN_TOKEN_1` 等所有 `HOTPLEX_*` 变量 | +| `"HOTPLEX_"` | 前缀匹配 | 阻止 `HOTPLEX_ADMIN_TOKEN_1`、`HOTPLEX_SECURITY_API_KEY_1` 等所有 `HOTPLEX_*` 变量 | | `"CLAUDE_"` | 前缀匹配 | 阻止 `CLAUDE_API_KEY`、`CLAUDE_MODEL` 等 | ### 2.3 配置驱动的扩展黑名单 @@ -193,8 +192,8 @@ for _, e := range environ { # .env # 网关内部变量(不会泄漏给 Worker) -HOTPLEX_JWT_SECRET=xxx HOTPLEX_ADMIN_TOKEN_1=xxx +HOTPLEX_SECURITY_API_KEY_1=xxx # Worker 专用变量(自动剥离前缀后注入 Worker 环境) HOTPLEX_WORKER_GITHUB_TOKEN=ghp_xxx # → Worker 收到 GITHUB_TOKEN=ghp_xxx @@ -373,13 +372,12 @@ CLI 层面防止 `.env` 文件覆盖关键系统变量: // Separate from worker blocklists since BuildEnv must pass HOME/PATH/USER // through to worker processes. var cliProtectedVars = map[string]bool{ - "HOME": true, - "PATH": true, - "USER": true, - "SHELL": true, - "CLAUDECODE": true, - "GATEWAY_ADDR": true, - "GATEWAY_TOKEN": true, + "HOME": true, + "PATH": true, + "USER": true, + "SHELL": true, + "CLAUDECODE": true, + "GATEWAY_ADDR": true, } // IsProtected reports whether an environment variable key should not be @@ -424,9 +422,9 @@ worker: # .env # === 网关内部变量(HOTPLEX_ 前缀,不会泄漏给 Worker)=== -HOTPLEX_JWT_SECRET=xxx HOTPLEX_ADMIN_TOKEN_1=xxx -HOTPLEX_GATEWAY_TOKEN=xxx +HOTPLEX_SECURITY_API_KEY_1=xxx +HOTPLEX_SECURITY_API_KEY_1=xxx HOTPLEX_SLACK_APP_TOKEN=xxx HOTPLEX_FEISHU_APP_ID=xxx diff --git a/docs/security/Security-Authentication.md b/docs/security/Security-Authentication.md index 416350ab..42e9a5d9 100644 --- a/docs/security/Security-Authentication.md +++ b/docs/security/Security-Authentication.md @@ -4,125 +4,180 @@ # Security: Authentication & Authorization Design -> HotPlex v1.0 WebSocket 认证授权设计,基于行业最佳实践。 +> HotPlex WebSocket 认证授权设计,基于 API Key + Bot ID 模型。 --- ## 1. 设计原则 -### 1.1 行业最佳实践 +### 1.1 核心决策 -| 来源 | 推荐方案 | -|------|----------| -| RFC 7519 (JWT) | 使用 `alg: ES256`,禁止 `HS256` | -| RFC 8725 (JOSE) | 始终验证 `aud` (Audience) claim | -| OAuth 2.0 | 短期 Access Token + Refresh Token | -| Discord Gateway | Session Resume 机制 | +| 决策 | 方案 | 说明 | +|------|------|------| +| 认证方式 | API Key | 简单、无状态、适合 WebSocket 长连接 | +| Bot 隔离 | `X-Bot-ID` Header | 请求级隔离,每个 Bot 只能操作自己的 Session | +| 多用户映射 | `APIKeyResolver` | 可选,将 API Key 映射到不同 userID | +| 生产要求 | 至少配置一个 API Key | 未配置时自动降级为 `anonymous`(开发模式) | -### 1.2 核心决策 +### 1.2 设计优势 -- ✅ **ES256 签名**:ECDSA P-256,性能优于 RSA,公钥可分发 -- ✅ **jti 防重放**:每 Token 唯一 ID,支持 Redis 黑名单 -- ✅ **aud 验证**:必须验证 JWT 接收方是 HotPlex Gateway -- ✅ **分层 TTL**:短 Access Token + 长 Session Token +- **简洁**:无需签名/验证 JWT,减少攻击面 +- **无状态**:API Key 在内存 map 中验证,支持热重载 +- **无损轮转**:编号式环境变量(`_1`、`_2`)支持在线替换 +- **灵活**:`APIKeyResolver` 可按需启用多用户隔离 --- -## 2. JWT 签名与验证 +## 2. API Key 认证 -### 2.1 签名算法选择 +### 2.1 密钥传递方式 -**推荐**:ES256 (ECDSA P-256 SHA-256) +`internal/security/auth.go` 提供双层 Key 提取: -```go -// 签名 -privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) -token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) -tokenString, _ := token.SignedString(privateKey) - -// 验证 -publicKey := &privateKey.PublicKey -token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) - } - return publicKey, nil -}) +1. **HTTP Header**(`X-API-Key`,可自定义) +2. **Query Parameter**(`api_key`,浏览器 WebSocket 客户端专用) + +```bash +# 方式 1:HTTP Header(推荐,CLI/服务端) +curl -i --no-buffer \ + -H "X-API-Key: your-api-key" \ + -H "X-Bot-ID: your-bot-id" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + http://localhost:8080/ws + +# 方式 2:Query Param(浏览器 WS 客户端) +ws://localhost:8080/ws?api_key=your-api-key&bot_id=your-bot-id ``` -**优势对比**: +### 2.2 密钥配置 -| 算法 | 签名大小 | 验证性能 | 公钥分发 | 推荐度 | -|------|----------|----------|----------|--------| -| ES256 | 64B | 快 | ✅ | ⭐⭐⭐⭐⭐ | -| RS256 | 256B | 中 | ✅ | ⭐⭐⭐⭐ | -| HS256 | 32B | 快 | ❌ | ⭐⭐(仅内部服务) | +```yaml +security: + api_keys: + - "ak-xxxxx" + - "ak-yyyyy" +``` + +或通过环境变量(编号式,支持无损轮转): + +```bash +HOTPLEX_SECURITY_API_KEY_1=ak-xxxxx +HOTPLEX_SECURITY_API_KEY_2=ak-yyyyy +``` + +**零密钥 = 开发模式**:未配置 API Key 时自动降级为 `anonymous` 用户,**生产环境必须配置至少一个 Key**。 + +### 2.3 热重载 + +`Authenticator.ReloadKeys()` 支持运行时更新密钥列表,无需重启: + +```bash +curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \ + http://localhost:9999/admin/config/reload +``` + +--- -### 2.2 JWT Claims 结构 +## 3. Bot ID 隔离 + +### 3.1 传递方式 + +Bot ID 通过以下方式传递: + +1. **HTTP Header**:`X-Bot-ID: your-bot-id` +2. **Query Parameter**:`?bot_id=your-bot-id` +3. **init 信封**:`data.bot_id` 字段 ```json { - "iss": "hotplex-auth-service", - "sub": "user_abc123", - "aud": "hotplex-gateway", - "exp": 1710000000, - "iat": 1709999700, - "jti": "550e8400-e29b-41d4-a716-446655440000", - "role": "user", - "scope": "session:create session:read session:delete", - "bot_id": "B0123456789", - "session_id": "sess_a1b2c3d4" + "event": { + "type": "init", + "data": { + "version": "aep/v1", + "worker_type": "claude_code", + "auth": { "token": "your-api-key" }, + "bot_id": "B12345" + } + } } ``` -| Claim | 类型 | 必需 | 说明 | -|-------|------|------|------| -| `iss` | string | ✅ | 签发者:hotplex-auth-service | -| `sub` | string | ✅ | 用户主体 ID | -| `aud` | string/[]string | ✅ | **必须验证**:hotplex-gateway | -| `exp` | int64 | ✅ | 过期时间(Unix timestamp) | -| `iat` | int64 | ✅ | 签发时间 | -| `jti` | string | ✅ | **JWT ID**:防重放攻击 | -| `role` | string | ✅ | RBAC 角色 | -| `scope` | string | ⚠️ | OAuth2 风格权限范围 | -| `bot_id` | string | ⚠️ | Bot 隔离标识 | -| `session_id` | string | ⚠️ | Session 绑定(可选) | - -### 2.3 Token 类型分层 - -| Token 类型 | TTL | 用途 | 存储 | -|-----------|-----|------|------| -| **Access Token** | 5 min | API 认证 | 内存/Redis | -| **Gateway Token** | 1 hour | WebSocket 连接保活 | Client 内存 | -| **Refresh Token** | 7 days | 刷新 Access Token | HttpOnly Cookie | +### 3.2 提取方式 + +```go +botID := security.BotIDFromRequest(r) +``` + +### 3.3 隔离规则 + +- `bot_id` 必须与 Session 所属 Bot **精确匹配** +- 跨 Bot 操作被硬拒绝,返回 `403 Forbidden` +- 未指定 `bot_id` 时不执行 Bot 级隔离检查 + +--- + +## 4. APIKeyResolver(多用户映射) + +### 4.1 默认行为 + +未设置 `APIKeyResolver` 时,所有 API Key 认证的请求统一使用 `api_user` 身份: + +``` +所有 API Key → api_user → Session ID 派生不区分用户 +``` + +### 4.2 自定义 Resolver + +通过 `security.SetKeyResolver()` 设置自定义映射: + +```go +security.SetKeyResolver(func(key string) (userID string, ok bool) { + // 从数据库/配置/API 查询 key 对应的 userID + userID, err := db.ResolveKeyToUser(key) + if err != nil { + return "", false + } + return userID, true +}) +``` + +设置后,不同 API Key 可映射到不同 userID,实现用户级会话隔离: + +``` +key: ak-alice → resolver → userID: "alice" → Session A +key: ak-bob → resolver → userID: "bob" → Session B +``` + +`ListSessions` API 按 userID 过滤,每个用户只看到自己的会话。 --- -## 3. WebSocket 认证流程 +## 5. WebSocket 认证流程 -### 3.1 双保险认证机制 +### 5.1 双保险认证机制 ``` ┌─────────────────────────────────────────────────────────────┐ │ WebSocket Authentication │ │ │ │ 1. 握手阶段 (Handshake) │ -│ Client ──── Cookie (可选) ────► Gateway │ +│ Client ──── X-API-Key Header ────► Gateway │ │ │ │ │ ▼ │ -│ Cookie 无效 ──► 401 Unauthorized │ +│ API Key 无效 ──► 401 Unauthorized │ │ │ │ │ ▼ │ -│ Cookie 有效 ──► 允许 Upgrade (101 Switching Protocols) │ +│ API Key 有效 ──► 允许 Upgrade (101 Switching Protocols) │ │ │ │ 2. 首条消息认证 (First Message) │ -│ Client ──── JWT (Authorization header) ──► Gateway │ +│ Client ──── init {auth.token} ────► Gateway │ │ │ │ │ ▼ │ -│ JWT 验证失败 ──► error + WS Close (1008) │ +│ 验证失败 ──────► error + WS Close (1008) │ │ │ │ │ ▼ │ -│ JWT 验证成功 ──► 绑定 user_id + session_id │ +│ 验证成功 ──────► 绑定 userID + botID + session_id │ │ │ │ 3. 消息循环 (Message Loop) │ │ Client ──── Envelope ──► Gateway │ @@ -133,62 +188,35 @@ token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (inter └─────────────────────────────────────────────────────────────┘ ``` -### 3.2 握手阶段认证 +### 5.2 握手阶段认证 -**方案 A:Cookie 认证(浏览器环境)** +**方案 A:API Key Header(CLI/Desktop)** ```http GET /gateway HTTP/1.1 Host: hotplex.example.com Upgrade: websocket Connection: Upgrade -Cookie: hotplex_token= +X-API-Key: ak-xxx +X-Bot-ID: bot-123 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 ``` -**验证逻辑**: - -```go -func (g *Gateway) ValidateHandshake(r *http.Request) error { - // 1. 检查 TLS - if !g.config.TLS.Required && r.TLS == nil { - return ErrTLSRequired - } - - // 2. 验证 Cookie 中的 Gateway Token - cookie, err := r.Cookie("hotplex_token") - if err != nil { - return ErrMissingAuthCookie - } - - claims, err := g.validateGatewayToken(cookie.Value) - if err != nil { - return err - } - - // 3. 存储用户信息到请求上下文 - r = r.WithContext(withUserClaims(r.Context(), claims)) - - return nil -} -``` - -**方案 B:Authorization Header(CLI/Desktop)** +**方案 B:Query Parameter(浏览器 WebSocket)** ```http -GET /gateway HTTP/1.1 +GET /gateway?api_key=ak-xxx&bot_id=bot-123 HTTP/1.1 Host: hotplex.example.com Upgrade: websocket Connection: Upgrade -Authorization: Bearer Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 ``` -### 3.3 首条消息认证(init.envelope) +### 5.3 首条消息认证(init.envelope) -**JWT Token 嵌入 Envelope**: +**API Key 嵌入 Envelope**: ```json { @@ -198,8 +226,9 @@ Sec-WebSocket-Version: 13 "protocol_version": "aep/v1", "client_caps": ["streaming", "tools"], "auth": { - "token": "" - } + "token": "your-api-key" + }, + "bot_id": "bot-123" } } ``` @@ -208,27 +237,27 @@ Sec-WebSocket-Version: 13 ```go func (g *Gateway) HandleInit(env *Envelope) (*Envelope, error) { - // 1. 提取 JWT Token + // 1. 提取 API Key tokenStr := env.Data["auth"].(map[string]interface{})["token"].(string) - // 2. 解析并验证 JWT - claims, err := g.jwtValidator.Validate(tokenStr) - if err != nil { - return nil, &AuthError{Code: "AUTHENTICATION_FAILED", Reason: err.Error()} + // 2. 验证 API Key + userID, ok := g.auth.Authenticate(tokenStr) + if !ok { + return nil, &AuthError{Code: "AUTHENTICATION_FAILED", Reason: "invalid API key"} } - // 3. 验证 jti 不在黑名单(防重放) - if g.redis.Exists("jwt:blacklist:" + claims.JTI) { - return nil, &AuthError{Code: "TOKEN_REVOKED", Reason: "jti already used"} + // 3. 使用 APIKeyResolver 映射用户身份(可选) + if resolver != nil { + if resolvedID, ok := resolver(tokenStr); ok { + userID = resolvedID + } } - // 4. 验证 aud - if !slices.Contains(claims.Audience, "hotplex-gateway") { - return nil, &AuthError{Code: "INVALID_AUDIENCE", Reason: "wrong audience"} - } + // 4. 提取 Bot ID + botID := env.Data["bot_id"].(string) - // 5. 绑定用户到 session - session := sm.CreateSession(claims.Sub, claims.BotID) + // 5. 创建 Session + session := sm.CreateSession(userID, botID) return &Envelope{ Kind: "init_ack", @@ -242,21 +271,21 @@ func (g *Gateway) HandleInit(env *Envelope) (*Envelope, error) { --- -## 4. Session Ownership 验证 +## 6. Session Ownership 验证 -### 4.1 JWT 绑定 Session +### 6.1 Session 绑定 ```go type Session struct { ID string - OwnerID string // JWT sub claim - BotID string // JWT bot_id claim + OwnerID string // userID(来自 APIKeyResolver 或默认 api_user) + BotID string // bot_id(来自 X-Bot-ID 或 init data) State SessionState CreatedAt int64 } ``` -### 4.2 Ownership 验证流程 +### 6.2 Ownership 验证流程 ```go func (sm *SessionManager) ValidateOwnership(sessionID, userID string) error { @@ -279,7 +308,7 @@ func (sm *SessionManager) ValidateOwnership(sessionID, userID string) error { } ``` -### 4.3 Admin API 权限矩阵 +### 6.3 Admin API 权限矩阵 | 端点 | Required Scope | 说明 | |------|----------------|------| @@ -290,282 +319,69 @@ func (sm *SessionManager) ValidateOwnership(sessionID, userID string) error { --- -## 5. Token 生命周期管理 - -### 5.1 jti 生成算法 - -> ⚠️ **必须使用 `crypto/rand` 生成 jti**,禁止使用 `math/rand` 或时间戳。 - -```go -// internal/security/jwt.go - -import ( - "crypto/rand" - "encoding/hex" - "fmt" -) - -// GenerateJTI 生成符合 RFC 7519 的 JWT ID -// 使用 crypto/rand 确保密码学安全,格式兼容 UUID v4 -func GenerateJTI() (string, error) { - b := make([]byte, 16) - n, err := rand.Read(b) - if err != nil { - return "", fmt.Errorf("crypto/rand unavailable: %w", err) - } - if n != 16 { - return "", fmt.Errorf("crypto/rand read insufficient bytes: got %d, want 16", n) - } - - // UUID v4 格式:xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - // 设置版本号(4)和变体(8/9/a/b) - b[6] = (b[6] & 0x0f) | 0x40 - b[8] = (b[8] & 0x3f) | 0x80 - - return fmt.Sprintf("%s-%s-%s-%s-%s", - hex.EncodeToString(b[0:4]), - hex.EncodeToString(b[4:6]), - hex.EncodeToString(b[6:8]), - hex.EncodeToString(b[8:10]), - hex.EncodeToString(b[10:16]), - ), nil -} -``` - -### 5.2 ES256 密钥管理 - -```go -// internal/security/key_manager.go - -type KeyManager interface { - GetPrivateKey() (*ecdsa.PrivateKey, error) - GetPublicKey() *ecdsa.PublicKey - PublicKeyPEM() ([]byte, error) - Rotate() error -} - -// FileKeyManager 从文件加载密钥,支持密钥轮换 -type FileKeyManager struct { - privateKeyPath string - publicKeyPath string - privateKey *ecdsa.PrivateKey // 缓存 - publicKey *ecdsa.PublicKey // 缓存 - mu sync.RWMutex -} - -func NewFileKeyManager(privatePath, publicPath string) (*FileKeyManager, error) { - km := &FileKeyManager{ - privateKeyPath: privatePath, - publicKeyPath: publicPath, - } - // 预加载密钥 - if err := km.loadKeys(); err != nil { - return nil, err - } - return km, nil -} - -func (km *FileKeyManager) loadKeys() error { - km.mu.Lock() - defer km.mu.Unlock() - - privPEM, err := os.ReadFile(km.privateKeyPath) - if err != nil { - return fmt.Errorf("read private key: %w", err) - } - - privBlock, _ := pem.Decode(privPEM) - if privBlock == nil { - return errors.New("invalid PEM in private key file") - } - - priv, err := x509.ParseECPrivateKey(privBlock.Bytes) - if err != nil { - return fmt.Errorf("parse EC private key: %w", err) - } - - km.privateKey = priv - km.publicKey = &priv.PublicKey - return nil -} - -func (km *FileKeyManager) GetPrivateKey() (*ecdsa.PrivateKey, error) { - km.mu.RLock() - defer km.mu.RUnlock() - if km.privateKey == nil { - return nil, errors.New("private key not loaded") - } - return km.privateKey, nil -} - -func (km *FileKeyManager) GetPublicKey() *ecdsa.PublicKey { - km.mu.RLock() - defer km.mu.RUnlock() - return km.publicKey -} - -func (km *FileKeyManager) PublicKeyPEM() ([]byte, error) { - km.mu.RLock() - defer km.mu.RUnlock() - - pubDER, err := x509.MarshalPKIXPublicKey(km.publicKey) - if err != nil { - return nil, fmt.Errorf("marshal public key: %w", err) - } - - return pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: pubDER, - }), nil -} +## 7. 密钥轮转 -// Rotate 重新加载密钥(用于密钥轮换时热更新) -func (km *FileKeyManager) Rotate() error { - return km.loadKeys() -} -``` +### 7.1 API Key 无损轮转 -**密钥文件格式**(PEM + PKCS8 / SEC1): +编号式环境变量支持在线替换: ```bash -# 生成 ES256 私钥(P-256 曲线,256-bit) -openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem -openssl ec -in private_key.pem -outform PEM -out private_key.pem - -# 导出公钥 -openssl ec -in private_key.pem -pubout -out public_key.pem +# 1. 添加新密钥 +export HOTPLEX_SECURITY_API_KEY_2="ak-new-key" -# 验证格式 -openssl ec -in private_key.pem -text -noout -# EC Private-Key 曲率为 prime256v1 (NID_X9_62_prime256v1) -``` - -### 5.3 Redis 黑名单 TTL - -> ⚠️ **jti TTL = access_token_ttl × 2**,允许时钟偏移(客户端与服务端时钟差 ≤ TTL)。 +# 2. 所有客户端切换到新密钥 -```go -const ( - AccessTokenTTL = 5 * time.Minute - JTIBlacklistTTL = AccessTokenTTL * 2 // 10 分钟,允许 ±5 分钟时钟偏移 -) +# 3. 移除旧密钥 +unset HOTPLEX_SECURITY_API_KEY_1 ``` -### 5.4 Token 刷新机制 - -```go -// Access Token 刷新 -func (s *AuthService) RefreshToken(refreshToken string) (*TokenPair, error) { - // 1. 验证 Refresh Token - claims, err := s.validateRefreshToken(refreshToken) - if err != nil { - return nil, err - } - - // 2. 验证 jti 不在黑名单(Rotation 机制) - if s.redis.Exists("refresh:blacklist:" + claims.JTI) { - return nil, ErrTokenReused // 单次使用 - } - - // 3. 将旧 jti 加入黑名单 - s.redis.Set("refresh:blacklist:"+claims.JTI, "1", 7*24*time.Hour) - - // 4. 签发新 Token Pair - return s.issueTokenPair(claims.Sub, claims.BotID) -} -``` +### 7.2 Admin Token 轮转(零停机) -### 5.5 吊销机制 +```bash +# 1. 生成新 Token +NEW_TOKEN=$(openssl rand -base64 32 | tr -d '/+=' | head -c 43) -**Redis Blacklist**: +# 2. 更新 _2(保留 _1) +export HOTPLEX_ADMIN_TOKEN_2="$NEW_TOKEN" -```go -// JWT 吊销 -func (s *AuthService) RevokeToken(jti string, ttl time.Duration) error { - return s.redis.Set("jwt:blacklist:"+jti, "revoked", ttl) -} +# 3. 所有客户端切换到 _2 -// 登出时吊销所有 Token -func (s *AuthService) Logout(userID string) error { - // 删除该用户的所有活跃 Token - return s.redis.Del("user:tokens:" + userID) -} +# 4. 更新 _1 为新 Token,清除旧 _2 ``` --- -## 6. 多 Bot 隔离方案 +## 8. 多 Bot 隔离方案 -### 6.1 决策:共享密钥 + bot_id 隔离 +### 8.1 决策:API Key + X-Bot-ID Header -> ✅ **采用共享 ES256 密钥 + JWT `bot_id` claim 隔离**,简化密钥分发运维。 +> **采用 API Key + Bot ID 隔离**,简化密钥分发运维。 ```go -// JWT 中包含 bot_id,Gateway 按 bot_id 路由到对应 Worker Pool +// Session 包含 BotID,Gateway 按 BotID 路由 type Session struct { ID string - OwnerID string // JWT sub claim - BotID string // JWT bot_id claim(用于 Worker Pool 隔离) + OwnerID string // userID(来自 APIKeyResolver 或默认 api_user) + BotID string // bot_id(来自 X-Bot-ID Header 或 init data) } ``` -**为何不用独立密钥**: -- 每个 Bot 需要独立密钥对,公钥分发复杂 -- 共享密钥 + `bot_id` 在内部服务间足够安全(外部攻击者无 bot_id) - -**何时需要独立密钥**: -- 多租户场景(每个租户自行管理密钥) -- 参见 v2.0-design 多实例分布式架构 - ---- - -## 7. 需要确认的决策点 - -### 6.1 关键问题 - -| # | 问题 | 选项 | 推荐 | -|---|------|------|------| -| 1 | Gateway Token TTL | 1小时 / 5分钟 | **1小时**(稳定性优先,WebSocket 长连接) | -| 2 | Refresh Token 存储 | HttpOnly Cookie / 客户端存储 | **HttpOnly Cookie**(浏览器)| -| 3 | 多 Bot 隔离 | 独立密钥 / 共享密钥 + bot_id | **共享密钥 + bot_id**(简化管理) | -| 4 | Session Resume | Discord 风格 / 无状态 | **有状态**(用户期望) | - -### 6.2 待确认的 TTL 配置 - -```yaml -# 方案 A:Gateway Token = 1小时(稳定优先) -auth: - gateway_token_ttl: 1h - access_token_ttl: 5m - refresh_token_ttl: 168h # 7 days - -# 方案 B:Gateway Token = 5分钟(安全优先,需定期刷新) -auth: - gateway_token_ttl: 5m - access_token_ttl: 5m - refresh_token_ttl: 168h -``` +**为何不用 JWT**: +- JWT 增加了签名/验证复杂度和攻击面 +- API Key + Bot ID 在内部服务间足够安全 +- 无状态验证,无需密钥轮转机制 +- 编号式环境变量原生支持无损轮转 --- -## 7. 安全检查清单 +## 9. 安全检查清单 -- 使用 ES256 签名(禁止 HS256) -- 验证 JWT `aud` claim -- 实现 jti 防重放(Redis 黑名单) -- WebSocket 握手阶段 Cookie/Header 认证 -- init.envelope 中 JWT 验证 +- 生产环境至少配置一个 API Key +- 配置 `X-Bot-ID` 实现多 Bot 隔离 +- 使用 `APIKeyResolver` 实现多用户隔离(可选) +- WebSocket 握手阶段 Header/Query 认证 +- init.envelope 中 API Key 验证 - Session Ownership 绑定 -- Token 刷新 + Rotation +- 编号式环境变量支持密钥轮转 - TLS 强制(生产环境) - 安全日志(认证失败、Ownership 不匹配) - ---- - -## 8. 参考资料 - -- [RFC 7519: JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) -- [RFC 8725: JSON Web Algorithms (JWE)](https://datatracker.ietf.org/doc/html/rfc8725) -- [RFC 6750: OAuth 2.0 Bearer Token Usage](https://datatracker.ietf.org/doc/html/rfc6750) -- [Discord Gateway Authentication](https://discord.com/developers/docs/topics/gateway#authorizing) -- [Auth0: JSON Web Token Best Practices](https://auth0.com/blog/ json-web-token-best-practices/) \ No newline at end of file diff --git a/e2e/helper_test.go b/e2e/helper_test.go index 7115b7ff..182d6d75 100644 --- a/e2e/helper_test.go +++ b/e2e/helper_test.go @@ -5,9 +5,6 @@ package e2e_test import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "fmt" "io" "log/slog" @@ -18,7 +15,6 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -290,16 +286,14 @@ func (m *mockStore) Close() error { // ─── Test Gateway Setup ───────────────────────────────────────────────────── type testGateway struct { - server *httptest.Server - hub *gateway.Hub - sm *session.Manager - bridge *gateway.Bridge - cfg *config.Config - store *mockStore - log *slog.Logger - cancel context.CancelFunc - jwtKey *ecdsa.PrivateKey - jwtValid *security.JWTValidator + server *httptest.Server + hub *gateway.Hub + sm *session.Manager + bridge *gateway.Bridge + cfg *config.Config + store *mockStore + log *slog.Logger + cancel context.CancelFunc } func setupTestGateway(t *testing.T) *testGateway { @@ -318,10 +312,6 @@ func setupTestGateway(t *testing.T) *testGateway { cfg.Pool.MaxIdlePerUser = 10 cfg.Pool.MaxMemoryPerUser = 0 - // Generate ES256 key for JWT testing. - jwtKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - jwtValidator := security.NewJWTValidator(jwtKey, "") - store := new(mockStore) store.Test(t) @@ -341,6 +331,9 @@ func setupTestGateway(t *testing.T) *testGateway { hub := gateway.NewHub(log, config.NewConfigStore(cfg, nil)) + auth := security.NewAuthenticator(&cfg.Security) + handler := gateway.NewHandler(gateway.HandlerDeps{Log: log, Hub: hub, SM: sm}) + sm.StateNotifier = func(ctx context.Context, sessionID string, state events.SessionState, message string) { env := events.NewEnvelope(aep.NewID(), sessionID, hub.NextSeq(sessionID), events.State, events.StateData{ State: state, @@ -349,28 +342,23 @@ func setupTestGateway(t *testing.T) *testGateway { _ = hub.SendToSession(ctx, env) } - handler := gateway.NewHandler(gateway.HandlerDeps{Log: log, Hub: hub, SM: sm, JWTValidator: jwtValidator}) bridge := gateway.NewBridge(gateway.BridgeDeps{Log: log, Hub: hub, SM: sm}) bridge.SetWorkerFactory(testWorkerFactory{}) - auth := security.NewAuthenticator(&cfg.Security, jwtValidator) - mux := http.NewServeMux() mux.Handle("/ws", hub.HandleHTTP(auth, handler, bridge)) server := httptest.NewServer(mux) tg := &testGateway{ - server: server, - hub: hub, - sm: sm, - bridge: bridge, - cfg: cfg, - store: store, - log: log, - cancel: cancel, - jwtKey: jwtKey, - jwtValid: jwtValidator, + server: server, + hub: hub, + sm: sm, + bridge: bridge, + cfg: cfg, + store: store, + log: log, + cancel: cancel, } t.Cleanup(func() { @@ -387,33 +375,12 @@ func (tg *testGateway) wsURL() string { return "ws" + strings.TrimPrefix(tg.server.URL, "http") + "/ws" } -func (tg *testGateway) generateToken(subject string, ttl time.Duration) string { - now := time.Now() - claims := jwt.MapClaims{ - "iss": "hotplex", - "sub": subject, - "aud": "gateway", - "exp": now.Add(ttl).Unix(), - "iat": now.Unix(), - "nbf": now.Unix(), - "jti": uuid.NewString(), - "scopes": []string{"session:write"}, - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - s, err := token.SignedString(tg.jwtKey) - if err != nil { - panic(err) - } - return s -} - func connectClient(t *testing.T, tg *testGateway, workerType string) *client.Client { t.Helper() - token := tg.generateToken("test-user", 5*time.Minute) c, err := client.New(context.Background(), client.URL(tg.wsURL()), client.WorkerType(workerType), - client.AuthToken(token), + client.BotID("test-bot"), client.APIKey("test-key"), ) require.NoError(t, err) diff --git a/e2e/session_test.go b/e2e/session_test.go index 13f3ba8c..1fd27430 100644 --- a/e2e/session_test.go +++ b/e2e/session_test.go @@ -152,12 +152,10 @@ func TestE2E_ResumeSession(t *testing.T) { t.Parallel() tg := setupTestGateway(t) - token := tg.generateToken("test-user", 5*time.Minute) - c1, err := client.New(context.Background(), client.URL(tg.wsURL()), client.WorkerType(wt.workerType), - client.AuthToken(token), + client.BotID("test-bot"), client.APIKey("test-key"), ) require.NoError(t, err) @@ -181,7 +179,7 @@ func TestE2E_ResumeSession(t *testing.T) { c2, err := client.New(context.Background(), client.URL(tg.wsURL()), client.WorkerType(wt.workerType), - client.AuthToken(token), + client.BotID("test-bot"), client.APIKey("test-key"), client.ClientSessionID(sessionID), ) diff --git a/examples/README.md b/examples/README.md index 551c2121..42667256 100644 --- a/examples/README.md +++ b/examples/README.md @@ -509,15 +509,9 @@ client = HotPlexClient( ### JWT 认证 -在 WebSocket 连接时传递 Bearer Token: +~~在 WebSocket 连接时传递 Bearer Token:~~ -```typescript -// TypeScript -const client = new HotPlexClient({ - url: "ws://localhost:8888", - authToken: "Bearer eyJhbGciOiJFUzI1NiIs...", -}); -``` +> **已移除**: JWT 认证已在 v1.17 中移除,所有客户端统一使用 API Key 认证。 ### Dev 模式 @@ -573,7 +567,7 @@ try { | `SESSION_NOT_FOUND` | 会话不存在 | 重新创建会话 | | `SESSION_TERMINATED` | 会话已终止 | 创建新会话 | | `SESSION_EXPIRED` | 会话过期 | 恢复会话或重建 | -| `UNAUTHORIZED` | 认证失败 | 检查 API Key/JWT | +| `UNAUTHORIZED` | 认证失败 | 检查 API Key | | `INVALID_INPUT` | 输入无效 | 检查消息格式 | | `WORKER_TIMEOUT` | Worker 超时 | 增加 timeout 或优化 Worker | diff --git a/examples/java-client/CODE_REVIEW_FIXES.md b/examples/java-client/CODE_REVIEW_FIXES.md index e6150d11..b65aabda 100644 --- a/examples/java-client/CODE_REVIEW_FIXES.md +++ b/examples/java-client/CODE_REVIEW_FIXES.md @@ -139,28 +139,14 @@ public void sendInput(String content, Map metadata) { ## Medium-Priority Issues (Not Fixed Yet) -### 7. **JWT Builder Duplication** (JwtTokenGenerator.java) -**Status**: IDENTIFIED but not fixed in this iteration - -**Issue**: Three near-identical token generation methods with repeated boilerplate. +### 7. **JwtTokenGenerator (REMOVED)** (JwtTokenGenerator.java) +**Status**: REMOVED - JWT authentication replaced with API Key + Bot ID -**Recommendation**: -```java -// Extract common builder configuration -private JwtBuilder baseBuilder(String subject, List scopes, long ttlSeconds) { - Instant now = Instant.now(); - return Jwts.builder() - .subject(subject) - .issuer(issuer) - .audience().add(audience).and() - .issuedAt(Date.from(now)) - .expiration(Date.from(now.plusSeconds(ttlSeconds))) - .claim("scopes", scopes) - .signWith(keyPair.getPrivate(), Jwts.SIG.ES256); -} -``` +The `JwtTokenGenerator` class has been removed from the codebase. Authentication is now handled via HTTP headers: +- `X-API-Key` for API key authentication +- `X-Bot-ID` for multi-bot isolation -**Impact**: Would reduce ~40 lines of code, prevent subtle bugs from divergent implementations. +The `HotPlexClient` builder now uses `.apiKey(String)` and `.botId(String)` instead of `.tokenGenerator(JwtTokenGenerator)`. --- @@ -246,7 +232,7 @@ Token generated but never used directly in example. - **3 new helper methods**: `requireConnected()`, `sendEnvelope()`, `clearListeners()` ### Deferred to Future Iteration -- **2 Medium-priority** refactoring opportunities (JWT builder, event routing) +- **1 Medium-priority** refactoring opportunity (event routing) - **3 Low-priority** cosmetic/minor issues ### Impact @@ -270,7 +256,7 @@ Token generated but never used directly in example. - Updated `sendInput()`, `sendControl()`, `sendPermissionResponse()` to use helpers 2. **JwtTokenGenerator.java** - - Reviewed for duplication (not modified in this iteration) + - REMOVED - JWT authentication replaced with API Key + Bot ID 3. **QuickStart.java** - Reviewed for issues (no changes required) @@ -279,11 +265,10 @@ Token generated but never used directly in example. ## Next Steps (Optional) -1. **JWT Refactoring**: Extract `baseBuilder()` method in `JwtTokenGenerator.java` -2. **Event Routing**: Use `EventKind` enum consistently in `routeEvent()` -3. **State Consolidation**: Derive `connected` from `state` enum -4. **Add Unit Tests**: Test listener cleanup, heartbeat timeout logic -5. **Performance Testing**: Verify no degradation under load +1. **Event Routing**: Use `EventKind` enum consistently in `routeEvent()` +2. **State Consolidation**: Derive `connected` from `state` enum +3. **Add Unit Tests**: Test listener cleanup, heartbeat timeout logic +4. **Performance Testing**: Verify no degradation under load --- diff --git a/examples/java-client/COMPLETION_SUMMARY.md b/examples/java-client/COMPLETION_SUMMARY.md index bcd7c769..168c73d1 100644 --- a/examples/java-client/COMPLETION_SUMMARY.md +++ b/examples/java-client/COMPLETION_SUMMARY.md @@ -45,8 +45,6 @@ examples/java-client/ │ │ ├── ControlData.java │ │ ├── PongData.java │ │ └── ProtocolConstants.java -│ └── security/ -│ └── JwtTokenGenerator.java # JWT 生成器 (191 行) ├── src/main/resources/ │ ├── application.yml │ └── logback.xml @@ -84,11 +82,10 @@ examples/java-client/ - ✅ `sendControl()` - 发送控制命令 - ✅ `sendPermissionResponse()` - 权限响应 -### 5. JWT 认证 -- ✅ ES256 签名 -- ✅ 与 Go 服务器兼容的密钥派生 -- ✅ 自动 token 生成 -- ✅ 可配置 TTL 和 scopes +### 5. 认证 +- ✅ API Key 认证 (X-API-Key header) +- ✅ Bot ID 多 bot 隔离 (X-Bot-ID header) +- ✅ 可配置认证凭据 --- @@ -128,8 +125,8 @@ examples/java-client/ ### Medium-Priority 重构 (1 项) -7. **JWT Builder 重复** ✅ - - 提取 `baseBuilder()` 辅助方法 +7. **Builder 方法重复** ✅ + - 提取辅助方法 - 减少 ~40 行重复代码 - 防止细微的 bug @@ -154,7 +151,7 @@ examples/java-client/ ```bash export HOTPLEX_GATEWAY_URL=ws://localhost:8888 -export HOTPLEX_SIGNING_KEY=your-256-bit-secret-key-min-32-characters +export HOTPLEX_API_KEY=your-api-key ``` ### 2. 编译项目 @@ -183,14 +180,12 @@ mvn exec:java -Dexec.mainClass="dev.hotplex.example.InteractiveExample" ### 基础用法 ```java -// 创建 JWT 生成器 -JwtTokenGenerator tokenGen = new JwtTokenGenerator(signingKey, "hotplex"); - // 创建客户端 HotPlexClient client = HotPlexClient.builder() .url("ws://localhost:8888") .workerType("claude-code") - .tokenGenerator(tokenGen) + .apiKey("your-api-key") + .botId("bot-123") .build(); // 注册事件监听器 @@ -242,7 +237,8 @@ client.disconnect(); HotPlexClient newClient = HotPlexClient.builder() .url("ws://localhost:8888") .workerType("claude-code") - .tokenGenerator(tokenGen) + .apiKey("your-api-key") + .botId("bot-123") .build(); InitAckData resumed = newClient.resume(sessionId).get(); @@ -258,7 +254,8 @@ InitAckData resumed = newClient.resume(sessionId).get(); HotPlexClient client = HotPlexClient.builder() .url(String) // 必需: Gateway URL .workerType(String) // 必需: Worker 类型 - .tokenGenerator(JwtTokenGenerator) // 可选: JWT 生成器 + .apiKey(String) // 可选: API Key + .botId(String) // 可选: Bot ID(多 bot 隔离) .build(); ``` diff --git a/examples/java-client/IMPLEMENTATION_SUMMARY.md b/examples/java-client/IMPLEMENTATION_SUMMARY.md index 38292f49..dec701fe 100644 --- a/examples/java-client/IMPLEMENTATION_SUMMARY.md +++ b/examples/java-client/IMPLEMENTATION_SUMMARY.md @@ -5,7 +5,7 @@ ```bash # 1. 设置环境变量 export HOTPLEX_GATEWAY_URL=ws://localhost:8888 -export HOTPLEX_SIGNING_KEY=your-256-bit-secret-key-min-32-chars +export HOTPLEX_API_KEY=your-api-key # 2. 编译项目 cd examples/java-client @@ -35,9 +35,9 @@ java -jar target/hotplex-client-1.7.2-SNAPSHOT.jar - ErrorCode 枚举 - SessionState 枚举 -- **安全**: JWT Token 生成器 - - ES256 签名 - - 与 Go 服务器兼容的密钥派生算法 +- **安全**: API Key + Bot ID 认证 + - API Key 通过 X-API-Key header 发送 + - Bot ID 通过 X-Bot-ID header 发送(多 bot 隔离) - **示例**: QuickStart.java - 最小可用示例 @@ -53,7 +53,6 @@ java -jar target/hotplex-client-1.7.2-SNAPSHOT.jar ```xml 17 - 0.12.6 @@ -69,20 +68,6 @@ java -jar target/hotplex-client-1.7.2-SNAPSHOT.jar jackson-databind - - - io.jsonwebtoken - jjwt-api - 0.12.6 - - - - - org.bouncycastle - bcprov-jdk18on - 1.78 - - org.slf4j @@ -127,8 +112,6 @@ examples/java-client/ │ │ ├── ControlData.java │ │ ├── PongData.java │ │ └── ProtocolConstants.java -│ └── security/ -│ └── JwtTokenGenerator.java # JWT 生成器 (191 行) ├── src/main/resources/ │ ├── application.yml # Spring Boot 配置 │ └── logback.xml # 日志配置 @@ -160,18 +143,6 @@ private static final Logger log = LoggerFactory.getLogger(HotPlexClient.class); // 之后: on("done", handler) ``` -### 问题 4: JWT Generator 参数不匹配 -**解决方案**: 添加构造函数重载,修复方法名 -```java -// 添加了两参数构造函数 -public JwtTokenGenerator(String secret, String issuer) { - this(secret, issuer, "hotplex-gateway"); -} - -// 修复方法名: generate -> generateToken -public String generateToken(String subject, List scopes, long ttlSeconds) -``` - ## 构建结果 ```bash @@ -205,4 +176,3 @@ $ mvn clean package -DskipTests - ✅ Spring Boot 3.2.5 - ✅ WebSocket RFC 6455 - ✅ AEP v1 Protocol -- ✅ ES256 JWT (与 Go 服务器兼容) diff --git a/examples/java-client/PROJECT_STATUS.md b/examples/java-client/PROJECT_STATUS.md index 39a3f1c9..4c82851c 100644 --- a/examples/java-client/PROJECT_STATUS.md +++ b/examples/java-client/PROJECT_STATUS.md @@ -49,10 +49,9 @@ - [x] 序列号管理 ### 安全特性 -- [x] ES256 JWT 认证 -- [x] 与 Go 服务器兼容的密钥派生 -- [x] 可配置 token TTL -- [x] Scope 权限系统 +- [x] API Key 认证 (X-API-Key header) +- [x] Bot ID 多 bot 隔离 (X-Bot-ID header) +- [x] 可配置认证凭据 ### 开发体验 - [x] Builder 模式 @@ -76,7 +75,7 @@ 6. ✅ 发送逻辑重复 - 提取 sendEnvelope() ### Medium Priority (1 项) -7. ✅ JWT Builder 重复 - 提取 baseBuilder() +7. ✅ Builder 方法重复 - 提取辅助方法 ### 代码质量 - ✅ 消除 ~87 行重复代码 @@ -117,7 +116,7 @@ mvn clean package -DskipTests ### 3. 快速示例 ```bash -export HOTPLEX_SIGNING_KEY=$(openssl rand -base64 32) +export HOTPLEX_API_KEY=your-api-key mvn exec:java -Dexec.mainClass="dev.hotplex.example.QuickStart" ``` **预期结果**: 连接到 gateway 并执行示例任务 @@ -135,10 +134,8 @@ examples/java-client/ │ │ ├── example/ # 示例程序 │ │ │ ├── QuickStart.java │ │ │ └── InteractiveExample.java -│ │ ├── protocol/ # 协议层 -│ │ │ ├── *.java (24 files) -│ │ └── security/ # 安全层 -│ │ └── JwtTokenGenerator.java +│ │ └── protocol/ # 协议层 +│ │ │ └── *.java (24 files) │ └── resources/ │ ├── application.yml │ └── logback.xml diff --git a/examples/java-client/README.md b/examples/java-client/README.md index 93172704..9cfd9dc3 100644 --- a/examples/java-client/README.md +++ b/examples/java-client/README.md @@ -58,7 +58,7 @@ public class Main { HotPlexClient client = HotPlexClient.builder() .url("ws://localhost:8888") .workerType(WorkerType.CLAUDE_CODE) - .authToken("your-api-key") + .apiKey("your-api-key") .build(); // Register event listeners @@ -107,7 +107,8 @@ public class Main { HotPlexClient client = HotPlexClient.builder() .url("ws://localhost:8888") // Required .workerType(WorkerType.CLAUDE_CODE) // Required - .authToken("your-api-key") // Optional + .apiKey("your-api-key") // Optional + .botId("bot-123") // Optional (multi-bot isolation) .sessionId("existing-session-id") // Optional (resume session) .reconnect(true) // Optional (default: true) .reconnectMaxAttempts(5) // Optional (default: 5) @@ -313,7 +314,7 @@ Map details = data.getDetails(); HotPlexClient client1 = HotPlexClient.builder() .url("ws://localhost:8888") .workerType(WorkerType.CLAUDE_CODE) - .authToken("your-key") + .apiKey("your-api-key") .build(); client1.connect(); @@ -326,7 +327,7 @@ client1.sendInput(InputData.builder() HotPlexClient client2 = HotPlexClient.builder() .url("ws://localhost:8888") .workerType(WorkerType.CLAUDE_CODE) - .authToken("your-key") + .apiKey("your-api-key") .sessionId(sessionId) // Resume .build(); @@ -445,7 +446,7 @@ try { |------|---------|--------| | `SESSION_NOT_FOUND` | Session doesn't exist | Create new session | | `SESSION_TERMINATED` | Session terminated | Create new session | -| `UNAUTHORIZED` | Invalid auth token | Check token | +| `UNAUTHORIZED` | Invalid API key | Check API key | | `INVALID_INPUT` | Malformed input | Check message format | --- @@ -480,7 +481,7 @@ class ClientTest { HotPlexClient client = HotPlexClient.builder() .url("ws://localhost:8888") .workerType(WorkerType.CLAUDE_CODE) - .authToken("test-key") + .apiKey("test-key") .build(); assertDoesNotThrow(() -> client.connect()); diff --git a/examples/java-client/pom.xml b/examples/java-client/pom.xml index 6176ad93..d8b3f41d 100644 --- a/examples/java-client/pom.xml +++ b/examples/java-client/pom.xml @@ -21,7 +21,6 @@ 17 - 0.12.6 @@ -43,32 +42,6 @@ jackson-datatype-jsr310 - - - io.jsonwebtoken - jjwt-api - ${jjwt.version} - - - io.jsonwebtoken - jjwt-impl - ${jjwt.version} - runtime - - - io.jsonwebtoken - jjwt-jackson - ${jjwt.version} - runtime - - - - - org.bouncycastle - bcprov-jdk18on - 1.78 - - org.slf4j diff --git a/examples/java-client/src/main/java/dev/hotplex/client/HotPlexClient.java b/examples/java-client/src/main/java/dev/hotplex/client/HotPlexClient.java index e117cefe..1be76bf4 100644 --- a/examples/java-client/src/main/java/dev/hotplex/client/HotPlexClient.java +++ b/examples/java-client/src/main/java/dev/hotplex/client/HotPlexClient.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.hotplex.protocol.*; -import dev.hotplex.security.JwtTokenGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.socket.*; @@ -44,7 +43,7 @@ public class HotPlexClient extends TextWebSocketHandler implements AutoCloseable private final String url; private final String workerType; private final String apiKey; - private final JwtTokenGenerator tokenGenerator; + private final String botId; private final InitData.InitConfig config; private final ObjectMapper objectMapper; @@ -101,7 +100,7 @@ private HotPlexClient(Builder builder) { this.url = builder.url; this.workerType = builder.workerType; this.apiKey = builder.apiKey; - this.tokenGenerator = builder.tokenGenerator; + this.botId = builder.botId; this.config = builder.config; this.objectMapper = new ObjectMapper(); @@ -183,6 +182,9 @@ private void doConnect() { if (apiKey != null && !apiKey.isEmpty()) { wsHeaders.add("X-API-Key", apiKey); } + if (botId != null && !botId.isEmpty()) { + wsHeaders.add("X-Bot-ID", botId); + } client.execute(this, wsHeaders, URI.create(url)); @@ -856,16 +858,7 @@ private Envelope createInitEnvelope() { "reasoning", "step", "control", "ping", "pong" )); initData.setClientCaps(clientCaps); - - // Add auth if token generator is available - if (tokenGenerator != null) { - // Default TTL: 1 hour (3600 seconds) - String token = tokenGenerator.generateToken("user", List.of("worker:use"), 3600); - InitData.InitAuth auth = new InitData.InitAuth(); - auth.setToken(token); - initData.setAuth(auth); - } - + return createEnvelope(EventKind.Init.getValue(), initData, "control"); } @@ -910,7 +903,7 @@ public static class Builder { private String url; private String workerType; private String apiKey; - private JwtTokenGenerator tokenGenerator; + private String botId; private InitData.InitConfig config; public Builder url(String url) { @@ -928,8 +921,8 @@ public Builder apiKey(String apiKey) { return this; } - public Builder tokenGenerator(JwtTokenGenerator tokenGenerator) { - this.tokenGenerator = tokenGenerator; + public Builder botId(String botId) { + this.botId = botId; return this; } diff --git a/examples/java-client/src/main/java/dev/hotplex/example/InteractiveExample.java b/examples/java-client/src/main/java/dev/hotplex/example/InteractiveExample.java index d71d8bb6..c8e228f7 100644 --- a/examples/java-client/src/main/java/dev/hotplex/example/InteractiveExample.java +++ b/examples/java-client/src/main/java/dev/hotplex/example/InteractiveExample.java @@ -2,7 +2,6 @@ import dev.hotplex.client.HotPlexClient; import dev.hotplex.protocol.*; -import dev.hotplex.security.JwtTokenGenerator; import java.util.Scanner; import java.util.concurrent.TimeUnit; @@ -21,7 +20,8 @@ *

* Environment Variables: * HOTPLEX_GATEWAY_URL - Gateway URL (default: ws://localhost:8888) - * HOTPLEX_SIGNING_KEY - JWT signing key (required) + * HOTPLEX_API_KEY - Gateway API key (required) + * HOTPLEX_BOT_ID - Bot ID for multi-bot isolation (optional) */ public class InteractiveExample { @@ -35,16 +35,15 @@ public static void main(String[] args) throws Exception { // Configuration from environment String gatewayUrl = getEnvOrDefault("HOTPLEX_GATEWAY_URL", DEFAULT_GATEWAY_URL); - String signingKey = requireEnv("HOTPLEX_SIGNING_KEY"); - - // Create JWT token generator - JwtTokenGenerator tokenGenerator = new JwtTokenGenerator(signingKey, "hotplex"); + String apiKey = requireEnv("HOTPLEX_API_KEY"); + String botId = System.getenv("HOTPLEX_BOT_ID"); // Create client HotPlexClient client = HotPlexClient.builder() .url(gatewayUrl) .workerType("claude-code") - .tokenGenerator(tokenGenerator) + .apiKey(apiKey) + .botId(botId) .build(); // Setup event handlers @@ -159,7 +158,7 @@ private static String requireEnv(String name) { String value = System.getenv(name); if (value == null || value.isEmpty()) { System.err.println("Error: " + name + " environment variable is required"); - System.err.println("Example: export " + name + "=your-256-bit-secret-key-min-32-chars"); + System.err.println("Example: export " + name + "=your-api-key"); System.exit(1); } return value; diff --git a/examples/java-client/src/main/java/dev/hotplex/example/QuickStart.java b/examples/java-client/src/main/java/dev/hotplex/example/QuickStart.java index b3f38007..c8d7cfa6 100644 --- a/examples/java-client/src/main/java/dev/hotplex/example/QuickStart.java +++ b/examples/java-client/src/main/java/dev/hotplex/example/QuickStart.java @@ -2,22 +2,22 @@ import dev.hotplex.client.HotPlexClient; import dev.hotplex.protocol.*; -import dev.hotplex.security.JwtTokenGenerator; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; /** * HotPlex Gateway - Quick Start Example - * + * * Minimal demo showing how to connect to the gateway and send a simple task. - * + * * Usage: * mvn compile && mvn exec:java -Dexec.mainClass="dev.hotplex.example.QuickStart" - * + * * Environment Variables: * HOTPLEX_GATEWAY_URL - Gateway URL (default: ws://localhost:8888) - * HOTPLEX_SIGNING_KEY - JWT signing key (required) + * HOTPLEX_API_KEY - Gateway API key (required) + * HOTPLEX_BOT_ID - Bot ID for multi-bot isolation (optional) * HOTPLEX_TASK - Task to execute (optional) */ public class QuickStart { @@ -33,10 +33,10 @@ public static void main(String[] args) throws Exception { if (gatewayUrl == null || gatewayUrl.isEmpty()) { gatewayUrl = DEFAULT_GATEWAY_URL; } - String signingKey = System.getenv("HOTPLEX_SIGNING_KEY"); - if (signingKey == null || signingKey.isEmpty()) { - System.err.println("Error: HOTPLEX_SIGNING_KEY environment variable is required"); - System.err.println("Example: export HOTPLEX_SIGNING_KEY=your-256-bit-secret-key"); + String apiKey = System.getenv("HOTPLEX_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + System.err.println("Error: HOTPLEX_API_KEY environment variable is required"); + System.err.println("Example: export HOTPLEX_API_KEY=your-api-key"); System.exit(1); } String task = System.getenv("HOTPLEX_TASK"); @@ -44,14 +44,14 @@ public static void main(String[] args) throws Exception { task = DEFAULT_TASK; } - // Create JWT token generator - JwtTokenGenerator tokenGenerator = new JwtTokenGenerator(signingKey, "hotplex"); + String botId = System.getenv("HOTPLEX_BOT_ID"); // Create client using builder and use try-with-resources try (HotPlexClient client = HotPlexClient.builder() .url(gatewayUrl) .workerType("claude-code") - .tokenGenerator(tokenGenerator) + .apiKey(apiKey) + .botId(botId) .build()) { // Latch for keeping main thread alive until done diff --git a/examples/java-client/src/main/java/dev/hotplex/security/JwtTokenGenerator.java b/examples/java-client/src/main/java/dev/hotplex/security/JwtTokenGenerator.java deleted file mode 100644 index 4dfd21f4..00000000 --- a/examples/java-client/src/main/java/dev/hotplex/security/JwtTokenGenerator.java +++ /dev/null @@ -1,205 +0,0 @@ -package dev.hotplex.security; - -import io.jsonwebtoken.JwtBuilder; -import io.jsonwebtoken.Jwts; - -import java.math.BigInteger; -import java.security.KeyFactory; -import java.security.KeyPair; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.Security; -import java.time.Instant; -import java.util.Date; -import java.util.List; -import java.util.UUID; - -import org.bouncycastle.asn1.x9.X9ECParameters; -import org.bouncycastle.crypto.digests.SHA256Digest; -import org.bouncycastle.crypto.generators.HKDFBytesGenerator; -import org.bouncycastle.crypto.params.HKDFParameters; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.jce.spec.ECParameterSpec; -import org.bouncycastle.math.ec.ECPoint; - -/** - * JWT Token Generator for HotPlex Gateway authentication. - * Uses ES256 (ECDSA P-256) signing method with HKDF key derivation from secret. - * Key derivation matches the Go implementation (v1.11.3+): - * HKDF-SHA256(secret, salt=nil, info="hotplex-ecdsa-p256") → scalar mod (N-1) + 1 - */ -public class JwtTokenGenerator { - - static { - Security.addProvider(new BouncyCastleProvider()); - } - - private final String issuer; - private final String audience; - private final KeyPair keyPair; - - /** - * Creates a new JwtTokenGenerator with the specified secret. - * The secret is used to derive an ECDSA P-256 key pair. - * - * @param secret the secret key (must be at least 32 bytes) - * @param issuer the token issuer (e.g., "hotplex") - */ - public JwtTokenGenerator(String secret, String issuer) { - this(secret, issuer, "hotplex-gateway"); - } - - /** - * Creates a new JwtTokenGenerator with the specified secret and audience. - * The secret is used to derive an ECDSA P-256 key pair. - * - * @param secret the secret key (must be at least 32 bytes) - * @param issuer the token issuer (e.g., "hotplex") - * @param audience the token audience (e.g., "hotplex-gateway") - */ - public JwtTokenGenerator(String secret, String issuer, String audience) { - this.issuer = issuer; - this.audience = audience; - this.keyPair = deriveKeyPair(secret); - } - - /** - * Derives an ECDSA P-256 key pair from the secret using HKDF (RFC 5869). - * Matches the Go gateway implementation (v1.11.3+): - * HKDF-SHA256(secret, salt=nil, info="hotplex-ecdsa-p256") → 32 bytes - * scalar = derived_bytes mod (N-1) + 1 - */ - private KeyPair deriveKeyPair(String secret) { - try { - X9ECParameters ecParams = org.bouncycastle.asn1.nist.NISTNamedCurves.getByName("P-256"); - org.bouncycastle.math.ec.ECCurve curve = ecParams.getCurve(); - BigInteger n = ecParams.getN(); - BigInteger nMinusOne = n.subtract(BigInteger.ONE); - ECPoint g = ecParams.getG(); - - // HKDF-SHA256 extract-then-expand, matching Go implementation - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); - hkdf.init(new HKDFParameters(secret.getBytes(), null, "hotplex-ecdsa-p256".getBytes())); - byte[] scalarBytes = new byte[32]; - hkdf.generateBytes(scalarBytes, 0, 32); - - // s = (scalar mod (N-1)) + 1 - BigInteger s = new BigInteger(1, scalarBytes); - s = s.mod(nMinusOne).add(BigInteger.ONE); - - // Create the private key using BC spec classes - ECParameterSpec ecSpec = new ECParameterSpec(curve, g, n); - org.bouncycastle.jce.spec.ECPrivateKeySpec privateKeySpec = - new org.bouncycastle.jce.spec.ECPrivateKeySpec(s, ecSpec); - KeyFactory keyFactory = KeyFactory.getInstance("ECDSA", "BC"); - PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec); - - // Derive public key: Q = s * G - ECPoint q = g.multiply(s).normalize(); - org.bouncycastle.jce.spec.ECPublicKeySpec pubKeySpec = - new org.bouncycastle.jce.spec.ECPublicKeySpec(q, ecSpec); - PublicKey publicKey = keyFactory.generatePublic(pubKeySpec); - - return new KeyPair(publicKey, privateKey); - } catch (Exception e) { - throw new RuntimeException("Failed to derive ECDSA key pair from secret", e); - } - } - - // ============================================================================ - // Private Helper Methods - // ============================================================================ - - /** - * Creates a base JWT builder with common claims. - * - * @param subject the subject (typically user ID) - * @param scopes the granted scopes - * @param ttlSeconds time-to-live in seconds - * @return the configured JWT builder - */ - private JwtBuilder baseBuilder(String subject, List scopes, long ttlSeconds) { - Instant now = Instant.now(); - Instant expiry = now.plusSeconds(ttlSeconds); - - return Jwts.builder() - .subject(subject) - .issuer(issuer) - .audience().add(audience).and() - .issuedAt(Date.from(now)) - .expiration(Date.from(expiry)) - .claim("scopes", scopes) - .signWith(keyPair.getPrivate(), Jwts.SIG.ES256); - } - - // ============================================================================ - // Token Generation Methods - // ============================================================================ - - /** - * Generates a JWT token with the specified claims. - * - * @param subject the subject (typically user ID) - * @param scopes the granted scopes - * @param ttlSeconds time-to-live in seconds - * @return the signed JWT token string - */ - public String generateToken(String subject, List scopes, long ttlSeconds) { - return baseBuilder(subject, scopes, ttlSeconds) - .id(generateJti()) - .compact(); - } - - /** - * Generates a JWT token with a specific JTI (JWT ID). - * - * @param subject the subject (typically user ID) - * @param scopes the granted scopes - * @param ttlSeconds time-to-live in seconds - * @param jti the JWT ID to use - * @return the signed JWT token string - */ - public String generateTokenWithJti(String subject, List scopes, long ttlSeconds, String jti) { - return baseBuilder(subject, scopes, ttlSeconds) - .id(jti) - .compact(); - } - - /** - * Generates a JWT token with custom additional claims. - * - * @param subject the subject (typically user ID) - * @param scopes the granted scopes - * @param ttlSeconds time-to-live in seconds - * @param extraClaims additional claims to include - * @return the signed JWT token string - */ - public String generateTokenWithClaims(String subject, List scopes, long ttlSeconds, - java.util.Map extraClaims) { - JwtBuilder builder = baseBuilder(subject, scopes, ttlSeconds).id(generateJti()); - - if (extraClaims != null) { - extraClaims.forEach(builder::claim); - } - - return builder.compact(); - } - - /** - * Gets the key pair for this generator. - * - * @return the derived ECDSA key pair - */ - public KeyPair getKeyPair() { - return keyPair; - } - - /** - * Generates a new JTI (JWT ID) using crypto-safe UUID. - * - * @return a unique JWT ID - */ - public static String generateJti() { - return UUID.randomUUID().toString(); - } -} diff --git a/examples/python-client/README.md b/examples/python-client/README.md index 7c5e7ada..a2b90807 100644 --- a/examples/python-client/README.md +++ b/examples/python-client/README.md @@ -83,7 +83,7 @@ from hotplex_client import HotPlexClient, WorkerType async with HotPlexClient( url="ws://localhost:8888", worker_type=WorkerType.CLAUDE_CODE, - auth_token="your-token", # 可选 + auth_token="your-token", # API Key(可选) ) as client: # 自动完成 init 握手 print(f"Session: {client.session_id}") diff --git a/examples/typescript-client/README.md b/examples/typescript-client/README.md index f4042217..9d89eaa1 100644 --- a/examples/typescript-client/README.md +++ b/examples/typescript-client/README.md @@ -108,7 +108,7 @@ new HotPlexClient(config: ClientConfig) |--------|------|----------|---------|-------------| | `url` | `string` | ✅ | - | Gateway WebSocket URL (e.g., `ws://localhost:8888`) | | `workerType` | `WorkerType` | ✅ | - | Worker type (`CLAUDE_CODE`, `OPENCODE_SERVER`, etc.) | -| `authToken` | `string` | ❌ | - | API key or JWT token | +| `authToken` | `string` | ❌ | - | API key for deferred browser auth | | `sessionId` | `string` | ❌ | auto | Resume existing session | | `reconnect` | `boolean` | ❌ | `true` | Enable auto-reconnection | | `reconnectMaxAttempts` | `number` | ❌ | `5` | Max reconnection attempts | @@ -404,7 +404,7 @@ client.on("error", (data) => { Error [UNAUTHORIZED]: Invalid API key ``` -**Solution**: Check `authToken` matches gateway config: +**Solution**: Verify `authToken` matches your gateway API key: ```typescript const client = new HotPlexClient({ authToken: process.env.HOTPLEX_API_KEY, // Ensure this is set diff --git a/examples/typescript-client/examples/complete.ts b/examples/typescript-client/examples/complete.ts index a5d9a88a..53d2e3e8 100644 --- a/examples/typescript-client/examples/complete.ts +++ b/examples/typescript-client/examples/complete.ts @@ -17,7 +17,6 @@ import * as readline from 'readline'; import { HotPlexClient, WorkerType, SessionState, ErrorCode } from '../src/index.js'; -import { generateTestToken } from '../scripts/generate-test-token.js'; // ============================================================================ // Types @@ -92,14 +91,11 @@ async function main() { printConfig(); - const token = await generateTestToken(); - // Create client const client = new HotPlexClient({ url: CONFIG.url + '/ws', workerType: WorkerType.ClaudeCode, - apiKey: 'dev-api-key', - authToken: token, + apiKey: process.env.HOTPLEX_API_KEY || 'dev-api-key', reconnect: { enabled: true, maxAttempts: 5, diff --git a/examples/typescript-client/examples/quickstart.ts b/examples/typescript-client/examples/quickstart.ts index 204f92ec..ad9c96c6 100644 --- a/examples/typescript-client/examples/quickstart.ts +++ b/examples/typescript-client/examples/quickstart.ts @@ -12,19 +12,15 @@ */ import { HotPlexClient, WorkerType } from '../src/index.js'; -import { generateTestToken } from '../scripts/generate-test-token.js'; async function main() { console.log('🚀 HotPlex Gateway - Quick Start\n'); - const token = await generateTestToken(); - // Create client connecting to local gateway const client = new HotPlexClient({ url: 'ws://localhost:8888/ws', workerType: WorkerType.ClaudeCode, - apiKey: 'dev-api-key', - authToken: token, + apiKey: process.env.HOTPLEX_API_KEY || 'dev-api-key', }); // Handle streaming output diff --git a/examples/typescript-client/scripts/generate-test-token.ts b/examples/typescript-client/scripts/generate-test-token.ts deleted file mode 100644 index 5850ad66..00000000 --- a/examples/typescript-client/scripts/generate-test-token.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { SignJWT, importJWK } from 'jose'; -import * as crypto from 'crypto'; - -const P256_N = BigInt('0xffffffff00000000ffffffffffffffffbce6faadaa5f1a7f9b7ee7a11e9b4bf65dd65db47c9c52f1ee3a9000000000000ffffffff'); -const P256_N_MINUS_1 = P256_N - BigInt(1); - -export async function generateTestToken( - secret: string = 'test-secret-key-for-development-32bytes', - userId: string = 'test-user', - issuer: string = 'hotplex', - ttlSeconds: number = 3600 -): Promise { - const secretBytes = Buffer.from(secret, 'utf-8'); - const d = Buffer.alloc(32); - d.set(secretBytes.slice(0, 32)); - - let scalar = BigInt('0x' + d.toString('hex')); - scalar = (scalar % (P256_N_MINUS_1)) + BigInt(1); - - const dHex = scalar.toString(16).padStart(64, '0'); - const dBuf = Buffer.from(dHex, 'hex'); - - const ecdh = crypto.createECDH('prime256v1'); - ecdh.setPrivateKey(dBuf); - - const jwk = { - kty: 'EC', - crv: 'P-256', - x: ecdh.getPublicKey().slice(1, 33).toString('base64url'), - y: ecdh.getPublicKey().slice(33, 65).toString('base64url'), - d: dBuf.toString('base64url'), - }; - - const privateKey = await importJWK(jwk, 'ES256'); - - const now = Math.floor(Date.now() / 1000); - - return new SignJWT({ user_id: userId, scopes: ['read', 'write'] }) - .setProtectedHeader({ alg: 'ES256', typ: 'JWT' }) - .setIssuer(issuer) - .setSubject(userId) - .setAudience(['hotplex-gateway']) - .setIssuedAt(now) - .setExpirationTime(now + ttlSeconds) - .setNotBefore(now) - .setJti(crypto.randomUUID()) - .sign(privateKey); -} - -const args = process.argv.slice(2); -let secret = 'test-secret-key-for-development-32bytes'; -let userId = 'test-user'; -let issuer = 'hotplex'; -let ttl = 3600; - -for (let i = 0; i < args.length; i++) { - if (args[i] === '--secret' && i + 1 < args.length) secret = args[++i]; - else if (args[i] === '--user' && i + 1 < args.length) userId = args[++i]; - else if (args[i] === '--issuer' && i + 1 < args.length) issuer = args[++i]; - else if (args[i] === '--ttl' && i + 1 < args.length) ttl = parseInt(args[++i], 10); -} - -generateTestToken(secret, userId, issuer, ttl) - .then(token => { - console.log('Generated JWT Token:\n' + token); - console.log('\nUse in client config: authToken: \'' + token + '\''); - }) - .catch(err => { - console.error('Error generating token:', err); - process.exit(1); - }); \ No newline at end of file diff --git a/examples/typescript-client/src/types.ts b/examples/typescript-client/src/types.ts index a2322e03..4b228324 100644 --- a/examples/typescript-client/src/types.ts +++ b/examples/typescript-client/src/types.ts @@ -167,6 +167,7 @@ export interface InitData { export interface InitAuth { token?: string; + bot_id?: string; } export interface InitConfig { diff --git a/go.mod b/go.mod index 17475fb6..3d7ea92a 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/anthropics/anthropic-sdk-go v1.41.0 github.com/cenkalti/backoff/v4 v4.3.0 github.com/fsnotify/fsnotify v1.7.0 - github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/golang-lru/v2 v2.0.7 diff --git a/go.sum b/go.sum index 5dac4f57..c9c61070 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,6 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= diff --git a/internal/cli/AGENTS.md b/internal/cli/AGENTS.md index 5329c073..8e89e029 100644 --- a/internal/cli/AGENTS.md +++ b/internal/cli/AGENTS.md @@ -14,7 +14,7 @@ cli/ environment.go # Environment: HOME, PATH, data dir writability, disk space messaging.go # Messaging: Slack/Feishu token presence and format validation runtime.go # Runtime: Go version, OS/arch compatibility, POSIX check - security.go # Security: JWT secret, admin tokens, TLS config + security.go # Security: admin tokens, TLS config stt.go # STT: Python deps, model files, ONNX validity *_test.go # Per-checker tests (table-driven) onboard/ diff --git a/internal/cli/checkers/config.go b/internal/cli/checkers/config.go index 372aac70..62a7d3b4 100644 --- a/internal/cli/checkers/config.go +++ b/internal/cli/checkers/config.go @@ -3,7 +3,6 @@ package checkers import ( "context" "crypto/rand" - "encoding/base64" "fmt" "net" "os" @@ -115,7 +114,7 @@ func (c configSyntaxChecker) Check(ctx context.Context) cli.Diagnostic { } } - _, err := config.Load(configPath, config.LoadOptions{}) + _, err := config.Load(configPath) if err == nil { return cli.Diagnostic{ Name: c.Name(), @@ -154,7 +153,7 @@ func (c configRequiredChecker) Check(ctx context.Context) cli.Diagnostic { } } - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return cli.Diagnostic{ Name: c.Name(), @@ -166,9 +165,6 @@ func (c configRequiredChecker) Check(ctx context.Context) cli.Diagnostic { } var missing []string - if len(cfg.Security.JWTSecret) == 0 { - missing = append(missing, "security.jwt_secret") - } hasWorker := cfg.Messaging.Slack.Enabled || cfg.Messaging.Feishu.Enabled if !hasWorker { @@ -221,7 +217,7 @@ func (c configValuesChecker) Check(ctx context.Context) cli.Diagnostic { } } - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return cli.Diagnostic{ Name: c.Name(), @@ -368,10 +364,6 @@ type configEnvVarsChecker struct{} func (c configEnvVarsChecker) Name() string { return "config.env_vars" } func (c configEnvVarsChecker) Category() string { return "config" } func (c configEnvVarsChecker) Check(ctx context.Context) cli.Diagnostic { - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - jwtSecret = os.Getenv("HOTPLEX_JWT_SECRET") - } adminToken := os.Getenv("ADMIN_TOKEN") if adminToken == "" { @@ -379,9 +371,7 @@ func (c configEnvVarsChecker) Check(ctx context.Context) cli.Diagnostic { } var missing []string - if jwtSecret == "" { - missing = append(missing, "JWT_SECRET (or HOTPLEX_JWT_SECRET)") - } + if adminToken == "" { missing = append(missing, "ADMIN_TOKEN (or HOTPLEX_ADMIN_TOKEN_1)") } @@ -423,14 +413,6 @@ func fixEnvVars() error { var lines []string - if !existing["HOTPLEX_JWT_SECRET"] { - b := make([]byte, 48) - if _, err := rand.Read(b); err != nil { - return fmt.Errorf("generate JWT secret: %w", err) - } - lines = append(lines, fmt.Sprintf("HOTPLEX_JWT_SECRET=%s", base64.StdEncoding.EncodeToString(b))) - } - if !existing["HOTPLEX_ADMIN_TOKEN_1"] { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/internal/cli/checkers/config_test.go b/internal/cli/checkers/config_test.go index 087934ea..925e6fe6 100644 --- a/internal/cli/checkers/config_test.go +++ b/internal/cli/checkers/config_test.go @@ -15,8 +15,7 @@ import ( func resetConfigPath() { SetConfigPath("") } // Tests using SetConfigPath cannot use t.Parallel because configPath is a -// package-level mutable variable shared with checkers in other files -// (e.g. filePermsChecker, resolveJWTSecret) that also read it. +// package-level mutable variable shared with checkers in other files. func TestSetConfigPath(t *testing.T) { SetConfigPath("/some/path/config.yaml") @@ -222,8 +221,6 @@ func TestExtractPort(t *testing.T) { func TestConfigEnvVars_Missing(t *testing.T) { // t.Setenv + package-level configPath — cannot use t.Parallel. - t.Setenv("JWT_SECRET", "") - t.Setenv("HOTPLEX_JWT_SECRET", "") t.Setenv("ADMIN_TOKEN", "") t.Setenv("HOTPLEX_ADMIN_TOKEN_1", "") @@ -232,13 +229,11 @@ func TestConfigEnvVars_Missing(t *testing.T) { require.Equal(t, cli.StatusWarn, d.Status) require.NotNil(t, d.FixFunc) - require.Contains(t, d.Detail, "JWT_SECRET") require.Contains(t, d.Detail, "ADMIN_TOKEN") } func TestConfigEnvVars_Present(t *testing.T) { // t.Setenv — cannot use t.Parallel. - t.Setenv("JWT_SECRET", "my-jwt-secret-value") t.Setenv("ADMIN_TOKEN", "my-admin-token-value") c := configEnvVarsChecker{} @@ -255,8 +250,6 @@ func TestConfigEnvVars_FixFunc(t *testing.T) { require.NoError(t, os.Chdir(dir)) t.Cleanup(func() { _ = os.Chdir(origDir) }) - t.Setenv("JWT_SECRET", "") - t.Setenv("HOTPLEX_JWT_SECRET", "") t.Setenv("ADMIN_TOKEN", "") t.Setenv("HOTPLEX_ADMIN_TOKEN_1", "") @@ -268,7 +261,6 @@ func TestConfigEnvVars_FixFunc(t *testing.T) { data, err := os.ReadFile(filepath.Join(dir, ".env")) require.NoError(t, err) - require.Contains(t, string(data), "HOTPLEX_JWT_SECRET=") require.Contains(t, string(data), "HOTPLEX_ADMIN_TOKEN_1=") } @@ -295,31 +287,9 @@ db: require.Contains(t, d.Detail, "Slack and Feishu") } -func TestConfigRequired_MissingJWT(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - dbDir := filepath.Join(dir, "data") - require.NoError(t, os.MkdirAll(dbDir, 0o755)) - content := "gateway:\n addr: \":8888\"\nadmin:\n addr: \":9999\"\n enabled: true\ndb:\n path: \"" + filepath.Join(dbDir, "test.db") + "\"\nmessaging:\n slack:\n enabled: true\n" - require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - - defer resetConfigPath() - SetConfigPath(path) - - c := configRequiredChecker{} - d := c.Check(context.Background()) - - require.Equal(t, cli.StatusFail, d.Status) - require.Contains(t, d.Detail, "security.jwt_secret") -} - func TestConfigRequired_AllPresent(t *testing.T) { // t.Setenv — cannot use t.Parallel. - // JWTSecret has mapstructure:"-" and is loaded via SecretsProvider, not from - // config file. The checker calls config.Load with empty LoadOptions, so - // JWTSecret is always empty. This test only verifies that the messaging - // check passes when Slack is enabled — JWT will still be reported missing. - t.Setenv("JWT_SECRET", "dGVzdC1zZWNyZXQta2V5LWZvci1qd3Q=") + // config file. The checker calls config.Load. dir := t.TempDir() path := filepath.Join(dir, "config.yaml") @@ -334,10 +304,7 @@ func TestConfigRequired_AllPresent(t *testing.T) { c := configRequiredChecker{} d := c.Check(context.Background()) - // Messaging is enabled so no messaging warning; JWT is still missing - // because the checker has no SecretsProvider. - require.Equal(t, cli.StatusFail, d.Status) - require.Contains(t, d.Detail, "security.jwt_secret") + require.Equal(t, cli.StatusPass, d.Status) } func TestConfigRequired_EmptyPath(t *testing.T) { diff --git a/internal/cli/checkers/config_yaml_test.go b/internal/cli/checkers/config_yaml_test.go index a4e82581..ae44f9a1 100644 --- a/internal/cli/checkers/config_yaml_test.go +++ b/internal/cli/checkers/config_yaml_test.go @@ -2,6 +2,7 @@ package checkers import ( "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -75,12 +76,14 @@ func TestReplaceYAMLValue(t *testing.T) { } func TestFixConfigValues(t *testing.T) { - setupTestConfigDir(t) + dir := t.TempDir() + configPath = filepath.Join(dir, "config.yaml") + defer func() { configPath = "" }() cfgContent := "gateway:\n addr: \":99999\"\nadmin:\n enabled: true\n addr: \":88888\"\ndb:\n path: \"\"\n" require.NoError(t, os.WriteFile(configPath, []byte(cfgContent), 0o600)) - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) require.NoError(t, err) err = fixConfigValues(cfg) diff --git a/internal/cli/checkers/messaging.go b/internal/cli/checkers/messaging.go index a8100590..3e9bb324 100644 --- a/internal/cli/checkers/messaging.go +++ b/internal/cli/checkers/messaging.go @@ -118,7 +118,7 @@ func (c multiBotConfigChecker) Check(ctx context.Context) cli.Diagnostic { } } - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return cli.Diagnostic{ Name: c.Name(), diff --git a/internal/cli/checkers/security.go b/internal/cli/checkers/security.go index a74de552..2ca5c8d3 100644 --- a/internal/cli/checkers/security.go +++ b/internal/cli/checkers/security.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/rand" - "encoding/base64" "fmt" "os" "os/exec" @@ -20,105 +19,6 @@ type pathPerm struct { perm os.FileMode } -// ─── security.jwt_strength ──────────────────────────────────────────────────── - -type jwtStrengthChecker struct{} - -func (c jwtStrengthChecker) Name() string { return "security.jwt_strength" } -func (c jwtStrengthChecker) Category() string { return "security" } -func (c jwtStrengthChecker) Check(ctx context.Context) cli.Diagnostic { - secret := resolveJWTSecret() - - if len(secret) == 0 { - return cli.Diagnostic{ - Name: c.Name(), - Category: c.Category(), - Status: cli.StatusFail, - Message: "JWT secret is empty", - FixHint: "Generate a strong JWT secret", - FixFunc: fixJWTStrength, - } - } - - if len(secret) < 32 { - return cli.Diagnostic{ - Name: c.Name(), - Category: c.Category(), - Status: cli.StatusFail, - Message: fmt.Sprintf("JWT secret too short (%d bytes, need >= 32)", len(secret)), - FixHint: "Generate a strong JWT secret (>= 32 bytes)", - FixFunc: fixJWTStrength, - } - } - - allSame := true - for i := 1; i < len(secret); i++ { - if secret[i] != secret[0] { - allSame = false - break - } - } - if allSame { - return cli.Diagnostic{ - Name: c.Name(), - Category: c.Category(), - Status: cli.StatusFail, - Message: "JWT secret has no entropy (all same character)", - FixHint: "Generate a strong JWT secret", - FixFunc: fixJWTStrength, - } - } - - return cli.Diagnostic{ - Name: c.Name(), - Category: c.Category(), - Status: cli.StatusPass, - Message: "JWT secret strong enough", - } -} - -func resolveJWTSecret() []byte { - if val := os.Getenv("JWT_SECRET"); val != "" { - return []byte(val) - } - if val := os.Getenv("HOTPLEX_JWT_SECRET"); val != "" { - return decodeBase64Secret(val) - } - if configPath != "" { - cfg, err := config.Load(configPath, config.LoadOptions{}) - if err == nil && len(cfg.Security.JWTSecret) > 0 { - return cfg.Security.JWTSecret - } - } - return nil -} - -func decodeBase64Secret(s string) []byte { - if d, err := base64.StdEncoding.DecodeString(s); err == nil { - return d - } - if d, err := base64.URLEncoding.DecodeString(s); err == nil { - return d - } - return []byte(s) -} - -func fixJWTStrength() error { - b := make([]byte, 48) - if _, err := rand.Read(b); err != nil { - return fmt.Errorf("generate secret: %w", err) - } - encoded := base64.StdEncoding.EncodeToString(b) - if err := writeEnvVar("HOTPLEX_JWT_SECRET", encoded); err != nil { - return err - } - return unsetEnvVar("JWT_SECRET") -} - -func init() { - cli.DefaultRegistry.Register(jwtStrengthChecker{}) -} - // ─── security.admin_token ───────────────────────────────────────────────────── type adminTokenChecker struct{} @@ -167,7 +67,7 @@ func resolveAdminToken() string { return val } if configPath != "" { - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err == nil && len(cfg.Admin.Tokens) > 0 { return cfg.Admin.Tokens[0] } @@ -218,7 +118,7 @@ func (c filePermsChecker) Check(ctx context.Context) cli.Diagnostic { checkPerm(filepath.Dir(configPath), 0o700) checkPerm(configPath, 0o600) - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err == nil && cfg.DB.Path != "" { checkPerm(filepath.Dir(cfg.DB.Path), 0o700) } @@ -333,7 +233,6 @@ func envFilePath() string { func writeEnvVar(key, value string) error { envPath := envFilePath() - // Read existing content and remove any existing entry for this key. var lines []string data, err := os.ReadFile(envPath) if err != nil && !os.IsNotExist(err) { diff --git a/internal/cli/checkers/security_fix_test.go b/internal/cli/checkers/security_fix_test.go index 5e14d320..05dc6b68 100644 --- a/internal/cli/checkers/security_fix_test.go +++ b/internal/cli/checkers/security_fix_test.go @@ -29,33 +29,6 @@ func setupTestWd(t *testing.T) string { return dir } -func TestFixJWTStrength(t *testing.T) { - dir := setupTestConfigDir(t) - envPath := filepath.Join(dir, ".env") - - require.NoError(t, fixJWTStrength()) - - data, err := os.ReadFile(envPath) - require.NoError(t, err) - content := string(data) - require.Contains(t, content, "HOTPLEX_JWT_SECRET=") - require.False(t, strings.Contains(content, "JWT_SECRET=") && !strings.Contains(content, "HOTPLEX_JWT_SECRET=")) -} - -func TestFixJWTStrength_RemovesLegacy(t *testing.T) { - dir := setupTestConfigDir(t) - envPath := filepath.Join(dir, ".env") - require.NoError(t, os.WriteFile(envPath, []byte("JWT_SECRET=old_value\n"), 0o600)) - - require.NoError(t, fixJWTStrength()) - - data, err := os.ReadFile(envPath) - require.NoError(t, err) - content := string(data) - require.Contains(t, content, "HOTPLEX_JWT_SECRET=") - require.NotContains(t, content, "JWT_SECRET=old_value") -} - func TestFixAdminToken(t *testing.T) { dir := setupTestConfigDir(t) envPath := filepath.Join(dir, ".env") diff --git a/internal/cli/checkers/security_test.go b/internal/cli/checkers/security_test.go index d9afd15e..37f1d05d 100644 --- a/internal/cli/checkers/security_test.go +++ b/internal/cli/checkers/security_test.go @@ -2,8 +2,6 @@ package checkers import ( "context" - "encoding/base64" - "strings" "testing" "github.com/stretchr/testify/require" @@ -15,59 +13,6 @@ import ( // - t.Setenv is incompatible with t.Parallel in Go's testing framework // - configPath is a package-level mutable variable subject to data races -func TestJWTStrength_Empty(t *testing.T) { - t.Setenv("JWT_SECRET", "") - t.Setenv("HOTPLEX_JWT_SECRET", "") - defer resetConfigPath() - SetConfigPath("") - - c := jwtStrengthChecker{} - d := c.Check(context.Background()) - - require.Contains(t, []cli.Status{cli.StatusFail, cli.StatusPass}, d.Status) - require.Equal(t, "security.jwt_strength", d.Name) -} - -func TestJWTStrength_TooShort(t *testing.T) { - t.Setenv("JWT_SECRET", "short") - - c := jwtStrengthChecker{} - d := c.Check(context.Background()) - - require.Equal(t, cli.StatusFail, d.Status) - require.Contains(t, d.Message, "too short") -} - -func TestJWTStrength_Strong(t *testing.T) { - t.Setenv("JWT_SECRET", strings.Repeat("aBcDeFgHiJkLmNoPqRsTuVwXyZ012345", 2)) - - c := jwtStrengthChecker{} - d := c.Check(context.Background()) - - require.Equal(t, cli.StatusPass, d.Status) -} - -func TestJWTStrength_NoEntropy(t *testing.T) { - t.Setenv("JWT_SECRET", strings.Repeat("a", 48)) - - c := jwtStrengthChecker{} - d := c.Check(context.Background()) - - require.Equal(t, cli.StatusFail, d.Status) - require.Contains(t, d.Message, "entropy") -} - -func TestJWTStrength_FixFunc(t *testing.T) { - t.Setenv("JWT_SECRET", "short") - - c := jwtStrengthChecker{} - d := c.Check(context.Background()) - - if d.FixFunc != nil { - require.NotNil(t, d.FixFunc) - } -} - func TestAdminToken_Empty(t *testing.T) { t.Setenv("ADMIN_TOKEN", "") t.Setenv("HOTPLEX_ADMIN_TOKEN_1", "") @@ -125,53 +70,6 @@ func TestAdminToken_FromHotplexEnv(t *testing.T) { require.Equal(t, cli.StatusPass, d.Status) } -func TestDecodeBase64Secret(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - wantFn func(t *testing.T, got []byte) - }{ - { - name: "valid standard base64", - input: base64.StdEncoding.EncodeToString([]byte("hello world")), - wantFn: func(t *testing.T, got []byte) { - require.Equal(t, []byte("hello world"), got) - }, - }, - { - name: "valid URL base64", - input: base64.URLEncoding.EncodeToString([]byte("url safe")), - wantFn: func(t *testing.T, got []byte) { - require.Equal(t, []byte("url safe"), got) - }, - }, - { - name: "invalid base64 returns raw string", - input: "not!base64!!!", - wantFn: func(t *testing.T, got []byte) { - require.Equal(t, []byte("not!base64!!!"), got) - }, - }, - { - name: "empty string", - input: "", - wantFn: func(t *testing.T, got []byte) { - require.Equal(t, []byte(""), got) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := decodeBase64Secret(tt.input) - tt.wantFn(t, got) - }) - } -} - func TestFilePerms(t *testing.T) { c := filePermsChecker{} d := c.Check(context.Background()) diff --git a/internal/cli/checkers/stt.go b/internal/cli/checkers/stt.go index 1f5aa80a..e370d0a9 100644 --- a/internal/cli/checkers/stt.go +++ b/internal/cli/checkers/stt.go @@ -90,7 +90,7 @@ func sttRequirements() (needsPython, needsFFmpeg bool) { if configPath == "" { return false, false } - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return false, false } diff --git a/internal/cli/checkers/tts.go b/internal/cli/checkers/tts.go index bd83b01a..0aa33da3 100644 --- a/internal/cli/checkers/tts.go +++ b/internal/cli/checkers/tts.go @@ -107,7 +107,7 @@ func ttsRequirements() ttsDeps { if configPath == "" { return ttsDeps{} } - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return ttsDeps{} } diff --git a/internal/cli/cron/client.go b/internal/cli/cron/client.go index 449d4ed4..d98a7c14 100644 --- a/internal/cli/cron/client.go +++ b/internal/cli/cron/client.go @@ -332,7 +332,7 @@ func loadConfig(configPath string) (*config.Config, error) { loadEnvFile(filepath.Dir(configPath)) - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return nil, fmt.Errorf("load config: %w", err) } diff --git a/internal/cli/onboard/wizard.go b/internal/cli/onboard/wizard.go index ae4af7f4..60d7fd91 100644 --- a/internal/cli/onboard/wizard.go +++ b/internal/cli/onboard/wizard.go @@ -141,7 +141,6 @@ type wizardContext struct { reader *bufio.Reader // nil in non-interactive mode // Step outputs — pre-fillable from existing config when step is skipped - jwtSecret string adminToken string workerType string slackCfg messagingPlatformConfig @@ -266,7 +265,7 @@ func Run(ctx context.Context, opts WizardOptions) (*WizardResult, error) { current++ displayStepProgress(current, totalSteps, "Write Environment") - s6 := stepWriteConfig(wctx.envPath, wctx.jwtSecret, wctx.adminToken, wctx.slackCfg, wctx.feishuCfg, configCreated, wctx.opts) + s6 := stepWriteConfig(wctx.envPath, wctx.adminToken, wctx.slackCfg, wctx.feishuCfg, configCreated, wctx.opts) result.add(s6) if s6.Status == "fail" { return result, fmt.Errorf("config write failed: %s", s6.Detail) @@ -412,7 +411,6 @@ func (wctx *wizardContext) buildSteps() []selectableStep { name: "required_config", label: "Secrets & Worker Type", selected: true, run: func(wctx *wizardContext) StepResult { return wctx.runRequiredConfig() }, prefill: func(wctx *wizardContext) { - wctx.jwtSecret = readExistingEnvValue(wctx.envPath, "HOTPLEX_JWT_SECRET") wctx.adminToken = readExistingEnvValue(wctx.envPath, "HOTPLEX_ADMIN_TOKEN_1") wctx.workerType = readExistingConfigValue(wctx.opts.ConfigPath, "worker.type") if wctx.workerType == "" { @@ -454,17 +452,11 @@ func (wctx *wizardContext) buildSteps() []selectableStep { func (wctx *wizardContext) runRequiredConfig() StepResult { if wctx.opts.NonInteractive { - wctx.jwtSecret = GenerateSecret() wctx.adminToken = GenerateSecret() wctx.workerType = "claude_code" return StepResult{Name: "required_config", Status: "pass", Detail: "auto-generated secrets, worker=claude_code"} } fmt.Fprint(os.Stderr, output.SectionHeader("Required Configuration")) - wctx.jwtSecret = prompt(wctx.reader, "JWT secret (enter to auto-generate)") - if wctx.jwtSecret == "" { - wctx.jwtSecret = GenerateSecret() - fmt.Fprintln(os.Stderr, " → Generated JWT secret") - } wctx.adminToken = prompt(wctx.reader, "Admin token (enter to auto-generate)") if wctx.adminToken == "" { wctx.adminToken = GenerateSecret() @@ -771,13 +763,13 @@ func stepConfigGen(opts WizardOptions, tplOpts ConfigTemplateOptions) (StepResul // ─── Step 6: Write config ─────────────────────────────────────────────────── -func stepWriteConfig(envPath, jwtSecret, adminToken string, slackCfg, feishuCfg messagingPlatformConfig, configCreated bool, opts WizardOptions) StepResult { - if err := os.WriteFile(envPath, []byte(buildEnvContent(jwtSecret, adminToken, slackCfg, feishuCfg, envPath)), 0o600); err != nil { +func stepWriteConfig(envPath, adminToken string, slackCfg, feishuCfg messagingPlatformConfig, configCreated bool, opts WizardOptions) StepResult { + if err := os.WriteFile(envPath, []byte(buildEnvContent(adminToken, slackCfg, feishuCfg, envPath)), 0o600); err != nil { return StepResult{Name: "write_config", Status: "fail", Detail: "write .env: " + err.Error()} } if configCreated { - if _, err := config.Load(opts.ConfigPath, config.LoadOptions{}); err != nil { + if _, err := config.Load(opts.ConfigPath); err != nil { return StepResult{Name: "write_config", Status: "fail", Detail: "config parse error: " + err.Error()} } } @@ -785,11 +777,10 @@ func stepWriteConfig(envPath, jwtSecret, adminToken string, slackCfg, feishuCfg return StepResult{Name: "write_config", Status: "pass", Detail: envPath} } -func buildEnvContent(jwtSecret, adminToken string, slackCfg, feishuCfg messagingPlatformConfig, existingEnvPath string) string { +func buildEnvContent(adminToken string, slackCfg, feishuCfg messagingPlatformConfig, existingEnvPath string) string { var b strings.Builder b.WriteString("# HotPlex Worker Gateway - Environment Configuration\n# Generated by onboard wizard\n\n") b.WriteString("# ── Security ──\n") - b.WriteString("HOTPLEX_JWT_SECRET=" + jwtSecret + "\n") b.WriteString("HOTPLEX_ADMIN_TOKEN_1=" + adminToken + "\n\n") b.WriteString("# ── Worker Commands (optional overrides) ──\n") b.WriteString("# HOTPLEX_WORKER_CLAUDE_CODE_COMMAND=claude\n") diff --git a/internal/cli/onboard/wizard_coverage_test.go b/internal/cli/onboard/wizard_coverage_test.go index 86ada310..d185bea2 100644 --- a/internal/cli/onboard/wizard_coverage_test.go +++ b/internal/cli/onboard/wizard_coverage_test.go @@ -215,7 +215,7 @@ func TestStepWriteEnv(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, ".env") - result := stepWriteConfig(envPath, "jwt-secret", "admin-token", + result := stepWriteConfig(envPath, "admin-token", messagingPlatformConfig{}, messagingPlatformConfig{}, false, WizardOptions{}) require.Equal(t, "pass", result.Status) @@ -223,15 +223,13 @@ func TestStepWriteEnv(t *testing.T) { data, err := os.ReadFile(envPath) require.NoError(t, err) content := string(data) - require.Contains(t, content, "HOTPLEX_JWT_SECRET=jwt-secret") require.Contains(t, content, "HOTPLEX_ADMIN_TOKEN_1=admin-token") } // ─── buildEnvContent ───────────────────────────────────────────────────────── func TestBuildEnvContent_NoPlatforms(t *testing.T) { - content := buildEnvContent("secret", "token", messagingPlatformConfig{}, messagingPlatformConfig{}, "") - require.Contains(t, content, "HOTPLEX_JWT_SECRET=secret") + content := buildEnvContent("token", messagingPlatformConfig{}, messagingPlatformConfig{}, "") require.Contains(t, content, "HOTPLEX_ADMIN_TOKEN_1=token") require.NotContains(t, content, "SLACK_ENABLED") require.NotContains(t, content, "FEISHU_ENABLED") @@ -242,7 +240,7 @@ func TestBuildEnvContent_WithSlack(t *testing.T) { enabled: true, credentials: map[string]string{"HOTPLEX_MESSAGING_SLACK_BOT_TOKEN": "xoxb-123"}, } - content := buildEnvContent("secret", "token", slackCfg, messagingPlatformConfig{}, "") + content := buildEnvContent("token", slackCfg, messagingPlatformConfig{}, "") require.Contains(t, content, "HOTPLEX_MESSAGING_SLACK_ENABLED=true") require.Contains(t, content, "HOTPLEX_MESSAGING_SLACK_BOT_TOKEN=xoxb-123") } @@ -253,7 +251,7 @@ func TestBuildEnvContent_KeptPlatform(t *testing.T) { require.NoError(t, os.WriteFile(envPath, []byte("HOTPLEX_MESSAGING_SLACK_BOT_TOKEN=xoxb-existing\n"), 0o600)) slackCfg := messagingPlatformConfig{enabled: true, kept: true, credentials: map[string]string{}} - content := buildEnvContent("secret", "token", slackCfg, messagingPlatformConfig{}, envPath) + content := buildEnvContent("token", slackCfg, messagingPlatformConfig{}, envPath) require.Contains(t, content, "HOTPLEX_MESSAGING_SLACK_BOT_TOKEN=xoxb-existing") } diff --git a/internal/cli/onboard/wizard_test.go b/internal/cli/onboard/wizard_test.go index 1ad66695..8fae3c1b 100644 --- a/internal/cli/onboard/wizard_test.go +++ b/internal/cli/onboard/wizard_test.go @@ -111,8 +111,7 @@ func TestBuildEnvContent(t *testing.T) { t.Parallel() t.Run("minimal", func(t *testing.T) { t.Parallel() - got := buildEnvContent("jwt", "admin", messagingPlatformConfig{}, messagingPlatformConfig{}, "") - require.Contains(t, got, "HOTPLEX_JWT_SECRET=jwt") + got := buildEnvContent("admin", messagingPlatformConfig{}, messagingPlatformConfig{}, "") require.Contains(t, got, "HOTPLEX_ADMIN_TOKEN_1=admin") require.NotContains(t, got, "HOTPLEX_WORKER_TYPE") require.NotContains(t, got, "# ── Slack ──") @@ -127,7 +126,7 @@ func TestBuildEnvContent(t *testing.T) { "HOTPLEX_MESSAGING_SLACK_APP_TOKEN": "xapp-test", }, } - got := buildEnvContent("jwt", "admin", slack, messagingPlatformConfig{}, "") + got := buildEnvContent("admin", slack, messagingPlatformConfig{}, "") require.Contains(t, got, "HOTPLEX_MESSAGING_SLACK_ENABLED=true") require.Contains(t, got, "HOTPLEX_MESSAGING_SLACK_BOT_TOKEN=xoxb-test") require.Contains(t, got, "HOTPLEX_MESSAGING_SLACK_APP_TOKEN=xapp-test") @@ -144,7 +143,7 @@ func TestBuildEnvContent(t *testing.T) { "HOTPLEX_MESSAGING_FEISHU_APP_SECRET": "secret456", }, } - got := buildEnvContent("jwt", "admin", messagingPlatformConfig{}, feishu, "") + got := buildEnvContent("admin", messagingPlatformConfig{}, feishu, "") require.Contains(t, got, "HOTPLEX_MESSAGING_FEISHU_ENABLED=true") require.Contains(t, got, "HOTPLEX_MESSAGING_FEISHU_APP_ID=cli_123") require.Contains(t, got, "HOTPLEX_MESSAGING_FEISHU_APP_SECRET=secret456") @@ -165,7 +164,7 @@ func TestBuildEnvContent(t *testing.T) { "HOTPLEX_MESSAGING_FEISHU_APP_ID": "cli_789", }, } - got := buildEnvContent("jwt", "admin", slack, feishu, "") + got := buildEnvContent("admin", slack, feishu, "") require.Contains(t, got, "# ── Slack ──") require.Contains(t, got, "# ── Feishu ──") }) @@ -176,7 +175,7 @@ func TestBuildEnvContent(t *testing.T) { enabled: true, credentials: map[string]string{}, } - got := buildEnvContent("jwt", "admin", slack, messagingPlatformConfig{}, "") + got := buildEnvContent("admin", slack, messagingPlatformConfig{}, "") require.Contains(t, got, "HOTPLEX_MESSAGING_SLACK_ENABLED=true") require.NotContains(t, got, "HOTPLEX_MESSAGING_SLACK_BOT_TOKEN=") }) @@ -186,16 +185,13 @@ func TestStepWriteConfig(t *testing.T) { t.Parallel() dir := t.TempDir() envPath := filepath.Join(dir, ".env") - s := stepWriteConfig(envPath, "jwt-secret", "admin-token", messagingPlatformConfig{}, messagingPlatformConfig{}, false, WizardOptions{}) + s := stepWriteConfig(envPath, "admin-token", messagingPlatformConfig{}, messagingPlatformConfig{}, false, WizardOptions{}) require.Equal(t, "pass", s.Status) - data, err := os.ReadFile(envPath) - require.NoError(t, err) - require.Contains(t, string(data), "HOTPLEX_JWT_SECRET=jwt-secret") } func TestStepWriteConfig_InvalidPath(t *testing.T) { t.Parallel() - s := stepWriteConfig("/nonexistent/dir/.env", "jwt", "admin", messagingPlatformConfig{}, messagingPlatformConfig{}, false, WizardOptions{}) + s := stepWriteConfig("/nonexistent/dir/.env", "admin", messagingPlatformConfig{}, messagingPlatformConfig{}, false, WizardOptions{}) require.Equal(t, "fail", s.Status) } @@ -354,7 +350,6 @@ func TestRun_NonInteractive(t *testing.T) { envData, readErr := os.ReadFile(filepath.Join(dir, ".env")) require.NoError(t, readErr) - require.Contains(t, string(envData), "HOTPLEX_JWT_SECRET=") require.Contains(t, string(envData), "HOTPLEX_ADMIN_TOKEN_1=") configData, configErr := os.ReadFile(configPath) diff --git a/internal/cli/slack/client.go b/internal/cli/slack/client.go index ddf0b6c1..b4fc3002 100644 --- a/internal/cli/slack/client.go +++ b/internal/cli/slack/client.go @@ -45,7 +45,7 @@ func LoadConfigAndClient(configPath string) (*config.Config, *slack.Client, erro loadEnvFile(filepath.Dir(configPath)) - cfg, err := config.Load(configPath, config.LoadOptions{}) + cfg, err := config.Load(configPath) if err != nil { return nil, nil, fmt.Errorf("load config: %w", err) } diff --git a/internal/config/AGENTS.md b/internal/config/AGENTS.md index bf6bd86c..ca4ab76f 100644 --- a/internal/config/AGENTS.md +++ b/internal/config/AGENTS.md @@ -1,12 +1,12 @@ # Config Package ## OVERVIEW -Central configuration via Viper + YAML with config inheritance, secrets provider chain, hot-reload watcher with audit log and rollback, and atomic ConfigStore with observer pattern. +Central configuration via Viper + YAML with config inheritance, HOTPLEX_* environment variables (AutomaticEnv), hot-reload watcher with audit log and rollback, and atomic ConfigStore with observer pattern. ## STRUCTURE ``` config/ - config.go # Config struct (20+ sub-configs), Load, Validate, SecretsProvider chain, path normalization (923 lines) + config.go # Config struct (20+ sub-configs), Load, Validate, path normalization (923 lines) store.go # ConfigStore: atomic Pointer[Config], Observer registration, Swap/Get watcher.go # Watcher: fsnotify debounce, diffConfigs, hot/static change split, audit trail, rollback paths_unix.go # DataDir/LogDir/PIDDir for Linux/macOS @@ -18,7 +18,7 @@ config/ |------|----------|-------| | Add config field | `config.go` Config struct | Add field + mapstructure tag + default in `applyDefaults()` | | Config inheritance | `config.go` Load | `Inherits` field → recursive merge with cycle detection | -| Secrets loading | `config.go` SecretsProvider | EnvSecretsProvider (HOTPLEX_*) → ChainedSecretsProvider | +| Secrets loading | `config.go` Load | Viper AutomaticEnv binds HOTPLEX_* env vars | | Hot reload | `watcher.go` Watcher | fsnotify → debounce 500ms → diffConfigs → hot/static split | | Audit trail | `watcher.go` ConfigChange | Field-level diff with old/new values, hot flag | | Rollback | `watcher.go` Rollback() | History ring buffer, latestIdx tracking | @@ -31,7 +31,7 @@ config/ **Config hierarchy**: `Config` contains 12 sub-configs (Gateway, DB, Worker, Security, Session, Pool, Log, Admin, WebChat, Messaging, AgentConfig, Skills). Each has mapstructure tags for Viper binding. -**Secrets provider chain**: `EnvSecretsProvider` reads HOTPLEX_* env vars → `ChainedSecretsProvider` tries multiple providers in order. JWT secret never from config file (mapstructure:"-"). +**Environment variables**: Viper AutomaticEnv binds all HOTPLEX_* prefixed environment variables. Secrets (API keys, tokens) must be provided via env vars, never committed to YAML. **Hot reload flow**: fsnotify event → debounce timer → reload() → Load() → Validate() → diffConfigs() → split hot/static → apply via ConfigStore.Swap() → notify observers. Static changes logged but not applied (require restart). @@ -41,7 +41,7 @@ config/ ## ANTI-PATTERNS - ❌ Read config without ConfigStore.Get() — direct field access bypasses atomic updates -- ❌ Add secrets to YAML config — use SecretsProvider or env vars only +- ❌ Add secrets to YAML config — use HOTPLEX_* environment variables only - ❌ Skip Validate() after Load — invalid configs silently accepted - ❌ Hot-reload static fields (addr, db.path) — they require restart - ❌ Forget `mapstructure:"-"` for sensitive fields — they'd appear in config dump diff --git a/internal/config/config.go b/internal/config/config.go index 6d541f4c..780d663c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,6 @@ package config import ( - "encoding/base64" "errors" "fmt" "log/slog" @@ -62,39 +61,7 @@ func expandEnvEntry(entry string) (string, bool) { return ExpandEnv(entry), true } -// SecretsProvider abstracts how secrets are retrieved. -type SecretsProvider interface { - // Get returns the secret value for the given key, or "" if not found. - Get(key string) string -} - -// EnvSecretsProvider retrieves secrets from environment variables. -type EnvSecretsProvider struct{} - -func NewEnvSecretsProvider() *EnvSecretsProvider { return &EnvSecretsProvider{} } - -func (p *EnvSecretsProvider) Get(key string) string { return os.Getenv(key) } - -// ChainedSecretsProvider tries providers in order until a value is found. -type ChainedSecretsProvider struct { - providers []SecretsProvider -} - -func NewChainedSecretsProvider(providers ...SecretsProvider) *ChainedSecretsProvider { - return &ChainedSecretsProvider{providers: providers} -} - -func (p *ChainedSecretsProvider) Get(key string) string { - for _, pr := range p.providers { - if val := pr.Get(key); val != "" { - return val - } - } - return "" -} - // Validate checks that all required configuration fields are set. -// Sensitive fields (JWTSecret) are validated separately via RequireSecrets. func (c *Config) Validate() []string { var errs []string @@ -124,25 +91,6 @@ func (c *Config) Validate() []string { return errs } -// RequireSecrets validates that all required sensitive fields are present. -// Returns an error listing any missing secrets. Call after Load. -func (c *Config) RequireSecrets() error { - var missing []string - if len(c.Security.JWTSecret) == 0 { - if os.Getenv("HOTPLEX_JWT_SECRET") != "" { - missing = append(missing, "security.jwt_secret (set but invalid: must decode to >= 32 bytes)") - } else { - missing = append(missing, "security.jwt_secret") - } - } else if len(c.Security.JWTSecret) < 32 { - missing = append(missing, "security.jwt_secret (must decode to >= 32 bytes for ES256)") - } - if len(missing) > 0 { - return fmt.Errorf("config: missing required secrets: %s (set via config file or HOTPLEX_JWT_SECRET env var)", strings.Join(missing, ", ")) - } - return nil -} - // ─── Config structs ─────────────────────────────────────────────────────────── // Config holds all gateway configuration. @@ -535,7 +483,6 @@ func (c AutoRetryConfig) Defaults() AutoRetryConfig { } // SecurityConfig holds auth and input validation settings. -// Sensitive fields (JWTSecret) must be provided via SecretsProvider after Load. type SecurityConfig struct { APIKeyHeader string `mapstructure:"api_key_header"` APIKeys []string `mapstructure:"api_keys"` @@ -543,8 +490,6 @@ type SecurityConfig struct { TLSCertFile string `mapstructure:"tls_cert_file"` TLSKeyFile string `mapstructure:"tls_key_file"` AllowedOrigins []string `mapstructure:"allowed_origins"` - JWTSecret []byte `mapstructure:"-"` // loaded via SecretsProvider, never from config file - JWTAudience string `mapstructure:"jwt_audience"` // APIKeyUsers maps environment variable names (or literal key values) to user IDs. // Enterprise multi-user: each API key gets a distinct identity for session isolation. @@ -601,7 +546,6 @@ type EventsConfig struct { // Default returns a Config with sensible production defaults. // All non-sensitive fields have values — the binary runs with zero config. -// Sensitive fields (JWTSecret) are left empty and must be provided separately. func Default() *Config { return &Config{ Gateway: GatewayConfig{ @@ -838,29 +782,21 @@ func normalizePathFields(fields ...*string) { // ─── Loading ───────────────────────────────────────────────────────────────── -// LoadOptions controls how configuration is loaded. -type LoadOptions struct { - // SecretsProvider supplies sensitive values (e.g. JWT secret, API keys). - // If nil, secrets are read from HOTPLEX_* environment variables. - SecretsProvider SecretsProvider -} - // ErrConfigCycle is returned when a config inheritance chain contains a cycle. var ErrConfigCycle = errors.New("config: inheritance cycle detected") // Load reads configuration from the given file path, then applies defaults -// and secrets. Configuration strategy: convention over configuration. +// and environment overrides. Configuration strategy: convention over configuration. // // Load order (later overrides earlier): // 1. Sensible defaults (Default()) // 2. Parent config file (via inherits field), recursively, with cycle detection // 3. Config file (YAML/JSON/TOML) — canonical source for non-sensitive values // 4. Environment variables (HOTPLEX_*) -// 5. Secrets provider — only sensitive fields (JWTSecret, etc.) // -// If filePath is empty, only defaults + environment + secrets are used. -func Load(filePath string, opts LoadOptions) (*Config, error) { - cfg, err := loadRecursive(filePath, opts, nil) +// If filePath is empty, only defaults + environment are used. +func Load(filePath string) (*Config, error) { + cfg, err := loadRecursive(filePath, nil) if err != nil { return nil, err } @@ -898,7 +834,6 @@ func Load(filePath string, opts LoadOptions) (*Config, error) { _ = v.BindEnv("worker.opencode_server.ready_poll_interval") _ = v.BindEnv("worker.opencode_server.http_timeout") _ = v.BindEnv("worker.opencode_server.password") - _ = v.BindEnv("security.jwt_audience") _ = v.BindEnv("security.api_key_header") _ = v.BindEnv("agent_config.enabled") _ = v.BindEnv("agent_config.config_dir") @@ -943,7 +878,7 @@ func Load(filePath string, opts LoadOptions) (*Config, error) { // loadRecursive loads a config file and its ancestors, detecting cycles. // visited tracks file paths already loaded in the current chain; nil on the root call. -func loadRecursive(filePath string, opts LoadOptions, visited []string) (*Config, error) { +func loadRecursive(filePath string, visited []string) (*Config, error) { // Start with defaults. cfg := Default() @@ -992,7 +927,7 @@ func loadRecursive(filePath string, opts LoadOptions, visited []string) (*Config if !filepath.IsAbs(parentFile) && filePath != "" { parentFile = filepath.Join(filepath.Dir(filePath), parentFile) } - parentCfg, err := loadRecursive(parentFile, opts, ancestors) + parentCfg, err := loadRecursive(parentFile, ancestors) if err != nil { return nil, fmt.Errorf("config: inherits %q: %w", parentFile, err) } @@ -1004,25 +939,6 @@ func loadRecursive(filePath string, opts LoadOptions, visited []string) (*Config *cfg = *parentCfg } - // Apply secrets via provider. If no provider given, fall back to env vars - // (HOTPLEX_JWT_SECRET etc.) for backwards compatibility. - sp := opts.SecretsProvider - if sp == nil { - sp = NewEnvSecretsProvider() - } - - // JWTSecret — only from secrets provider, never from config file. - // The secret is base64-encoded (standard or URL-safe) and decoded before use. - // This matches the client token generator's key loading behavior. - if secret := sp.Get("HOTPLEX_JWT_SECRET"); secret != "" { - cfg.Security.JWTSecret = decodeJWTSecret(secret) - if cfg.Security.JWTSecret == nil { - if _, loaded := warnedEnvEntries.LoadOrStore("jwt_secret_invalid", true); !loaded { - slog.Warn("config: HOTPLEX_JWT_SECRET set but invalid format (must be 32-byte raw or base64-encoded 32 bytes)", "length", len(secret)) - } - } - } - // Expand env vars in token slices (supports ${VAR} references in config files). for i, t := range cfg.Admin.Tokens { cfg.Admin.Tokens[i] = ExpandEnv(t) @@ -1439,20 +1355,3 @@ func setSliceField(target any, field, value string) error { f.Set(reflect.ValueOf(slice)) return nil } - -// decodeJWTSecret decodes a base64-encoded JWT secret. -// It supports both standard base64 and URL-safe base64 (with or without padding). -// Requires >= 32 bytes (HKDF-derived ECDSA key needs sufficient entropy). -func decodeJWTSecret(secret string) []byte { - if decoded, err := base64.StdEncoding.DecodeString(secret); err == nil && len(decoded) >= 32 { - return decoded - } - if decoded, err := base64.URLEncoding.DecodeString(secret); err == nil && len(decoded) >= 32 { - return decoded - } - raw := []byte(secret) - if len(raw) >= 32 { - return raw - } - return nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a927f360..5d3796d7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,7 +1,6 @@ package config import ( - "encoding/base64" "os" "path/filepath" "strings" @@ -284,49 +283,10 @@ func TestExpandEnvEntry(t *testing.T) { } } -func TestEnvSecretsProvider(t *testing.T) { - t.Parallel() - - os.Setenv("TEST_SECRET", "secret123") - defer os.Unsetenv("TEST_SECRET") - - p := NewEnvSecretsProvider() - require.Equal(t, "secret123", p.Get("TEST_SECRET")) - require.Empty(t, p.Get("NONEXISTENT")) -} - -func TestChainedSecretsProvider(t *testing.T) { - t.Parallel() - - p := NewChainedSecretsProvider( - &staticProvider{data: map[string]string{"key1": "from-first"}}, - &staticProvider{data: map[string]string{"key1": "from-second", "key2": "from-second"}}, - ) - - require.Equal(t, "from-first", p.Get("key1")) // first provider wins - require.Equal(t, "from-second", p.Get("key2")) // only in second - require.Empty(t, p.Get("key3")) // neither has it -} - -type staticProvider struct { - data map[string]string -} - -func (p *staticProvider) Get(key string) string { - return p.data[key] -} - -func TestChainedSecretsProvider_Empty(t *testing.T) { - t.Parallel() - - p := NewChainedSecretsProvider() - require.Empty(t, p.Get("anything")) -} - func TestLoad_FileNotFound(t *testing.T) { t.Parallel() - _, err := Load("/nonexistent/config.yaml", LoadOptions{}) + _, err := Load("/nonexistent/config.yaml") require.Error(t, err) } @@ -347,7 +307,7 @@ func TestLoad_Inheritance_CycleDetection(t *testing.T) { t.Fatal(err) } - _, err := Load(baseCfg, LoadOptions{}) + _, err := Load(baseCfg) require.Error(t, err) require.ErrorIs(t, err, ErrConfigCycle) } @@ -364,7 +324,7 @@ func TestLoad_Inheritance_SelfReference(t *testing.T) { } tmp.Close() - _, err = Load(tmp.Name(), LoadOptions{}) + _, err = Load(tmp.Name()) require.Error(t, err) require.ErrorIs(t, err, ErrConfigCycle) } @@ -387,7 +347,7 @@ func TestLoad_Inheritance_ThreeLevelChain(t *testing.T) { t.Fatal(err) } - cfg, err := Load(leafCfg, LoadOptions{}) + cfg, err := Load(leafCfg) require.NoError(t, err) // Leaf overrides mid, mid overrides base. require.Equal(t, ":7070", cfg.Gateway.Addr) @@ -406,7 +366,7 @@ func TestLoad_Inheritance_NoInherits(t *testing.T) { } tmp.Close() - cfg, err := Load(tmp.Name(), LoadOptions{}) + cfg, err := Load(tmp.Name()) require.NoError(t, err) require.Equal(t, ":6060", cfg.Gateway.Addr) require.Equal(t, 5, cfg.Pool.MaxSize) @@ -433,7 +393,7 @@ func TestLoad_Inheritance_PathExpansion(t *testing.T) { t.Fatal(err) } - cfg, err := Load(childPath, LoadOptions{}) + cfg, err := Load(childPath) require.NoError(t, err) require.Equal(t, ":8001", cfg.Gateway.Addr) } @@ -448,7 +408,7 @@ func TestLoad_NumberedEnv(t *testing.T) { os.Unsetenv("HOTPLEX_SECURITY_API_KEY_1") }() - cfg, err := Load("", LoadOptions{}) + cfg, err := Load("") require.NoError(t, err) require.Contains(t, cfg.Admin.Tokens, "token1") @@ -456,57 +416,6 @@ func TestLoad_NumberedEnv(t *testing.T) { require.Contains(t, cfg.Security.APIKeys, "key1") } -func TestConfig_RequireSecrets(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cfg Config - expectError bool - }{ - { - name: "JWT secret present", - cfg: Config{ - Security: SecurityConfig{ - JWTSecret: decodeJWTSecret("c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0MTIzNDU="), - }, - }, - expectError: false, - }, - { - name: "JWT secret missing", - cfg: Config{ - Security: SecurityConfig{ - JWTSecret: []byte{}, - }, - }, - expectError: true, - }, - { - name: "JWT secret present but short", - cfg: Config{ - Security: SecurityConfig{ - JWTSecret: decodeJWTSecret("c2hvcnQ="), // "short" in base64 - }, - }, - expectError: true, // Now rejects non-32-byte keys - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - err := tt.cfg.RequireSecrets() - if tt.expectError { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - func TestAutoRetryConfig_Defaults(t *testing.T) { t.Parallel() @@ -558,76 +467,6 @@ func TestAutoRetryConfig_Defaults(t *testing.T) { } } -func TestDecodeJWTSecret(t *testing.T) { - t.Parallel() - - // Valid 32-byte secret - validSecret32 := make([]byte, 32) - for i := range validSecret32 { - validSecret32[i] = byte(i) - } - validSecretB64 := base64.StdEncoding.EncodeToString(validSecret32) - validSecretURLB64 := base64.URLEncoding.EncodeToString(validSecret32) - - // 48-byte secret (e.g. openssl rand -base64 48) - validSecret48 := make([]byte, 48) - for i := range validSecret48 { - validSecret48[i] = byte(i) - } - validSecret48B64 := base64.StdEncoding.EncodeToString(validSecret48) - - tests := []struct { - name string - input string - expected []byte - }{ - { - name: "standard base64 32 bytes", - input: validSecretB64, - expected: validSecret32, - }, - { - name: "URL-safe base64 32 bytes", - input: validSecretURLB64, - expected: validSecret32, - }, - { - name: "raw 32-byte string", - input: string(validSecret32), - expected: validSecret32, - }, - { - name: "base64 48 bytes accepted", - input: validSecret48B64, - expected: validSecret48, - }, - { - name: "raw 48-byte string accepted", - input: string(validSecret48), - expected: validSecret48, - }, - { - name: "other string (not 32 bytes)", - input: "short", - expected: nil, - }, - { - name: "empty string", - input: "", - expected: nil, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result := decodeJWTSecret(tt.input) - require.Equal(t, tt.expected, result) - }) - } -} - func TestNormalizePath(t *testing.T) { // Not parallel because it modifies global env var HOME // Save original HOME for restoration @@ -902,7 +741,7 @@ db: require.NoError(t, err) tempFile.Close() - cfg, err := Load(tempFile.Name(), LoadOptions{}) + cfg, err := Load(tempFile.Name()) require.NoError(t, err) require.NotNil(t, cfg) require.Equal(t, ":8888", cfg.Gateway.Addr) diff --git a/internal/config/watcher.go b/internal/config/watcher.go index de4f54c4..7c40c155 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -43,7 +43,6 @@ var staticFields = map[string]bool{ "security.tls_enabled": true, "security.tls_cert_file": true, "security.tls_key_file": true, - "security.jwt_secret": true, "db.path": true, "db.wal_mode": true, } @@ -61,7 +60,6 @@ type ConfigChange struct { type Watcher struct { log *slog.Logger path string - sp SecretsProvider // used on reload to supply secrets viper *fsnotify.Watcher debounce time.Duration onChange func(*Config) // called with the new config after hot reload @@ -97,23 +95,18 @@ type Watcher struct { // NewWatcher creates a file-system watcher for hot config reloading. // path: absolute path to the config file. -// sp: SecretsProvider used on reload to supply sensitive values. If nil, falls back to env vars. // store: central ConfigStore for atomic config propagation. If nil, falls back to onChange callback only. // onChange: called (in a goroutine) when hot-reloadable fields change. // onStatic: called (in a goroutine) when static fields change. // The watcher does not start until Start() is called. // The caller should pass the initially loaded config via SetInitial after calling NewWatcher. -func NewWatcher(log *slog.Logger, path string, sp SecretsProvider, store *ConfigStore, onChange func(*Config), onStatic func(string)) *Watcher { +func NewWatcher(log *slog.Logger, path string, store *ConfigStore, onChange func(*Config), onStatic func(string)) *Watcher { if log == nil { log = slog.Default() } - if sp == nil { - sp = NewEnvSecretsProvider() - } return &Watcher{ log: log, path: path, - sp: sp, store: store, debounce: 500 * time.Millisecond, onChange: onChange, @@ -206,7 +199,7 @@ func (w *Watcher) reload() { prev := w.Latest() - newCfg, err := Load(w.path, LoadOptions{SecretsProvider: w.sp}) + newCfg, err := Load(w.path) if err != nil { w.log.Warn("config: reload failed", "err", err) return @@ -337,8 +330,7 @@ func diffConfigs(prev, next *Config) []ConfigChange { // sensitiveFields are fields whose values should be redacted in audit logs. var sensitiveFields = map[string]bool{ - "security.api_keys": true, - "security.jwt_secret": true, + "security.api_keys": true, } // resolveField extracts a config field value by its dot-separated path diff --git a/internal/config/watcher_test.go b/internal/config/watcher_test.go index 72c304ff..970d3289 100644 --- a/internal/config/watcher_test.go +++ b/internal/config/watcher_test.go @@ -18,7 +18,7 @@ func TestNewWatcher(t *testing.T) { t.Run("with nil logger and store", func(t *testing.T) { t.Parallel() - w := NewWatcher(nil, "/tmp/test.yaml", nil, nil, nil, nil) + w := NewWatcher(nil, "/tmp/test.yaml", nil, nil, nil) require.NotNil(t, w) require.NotNil(t, w.log) require.Equal(t, "/tmp/test.yaml", w.path) @@ -28,9 +28,8 @@ func TestNewWatcher(t *testing.T) { t.Run("with custom params", func(t *testing.T) { t.Parallel() logger := slog.Default() - sp := NewEnvSecretsProvider() store := NewConfigStore(Default(), logger) - w := NewWatcher(logger, "/tmp/test.yaml", sp, store, nil, nil) + w := NewWatcher(logger, "/tmp/test.yaml", store, nil, nil) require.NotNil(t, w) require.Equal(t, logger, w.log) require.Equal(t, store, w.store) @@ -60,7 +59,7 @@ func TestWatcher_Reload_And_ConfigStore(t *testing.T) { mu.Unlock() }) - w := NewWatcher(logger, tmpFile, nil, store, nil, nil) + w := NewWatcher(logger, tmpFile, store, nil, nil) w.SetInitial(cfg) // Modify file @@ -124,7 +123,7 @@ func TestWatcher_Rollback_Triggers_Store(t *testing.T) { cfg2.Gateway.Addr = "127.0.0.1:8082" store := NewConfigStore(cfg2, nil) - w := NewWatcher(nil, "/tmp/test.yaml", nil, store, nil, nil) + w := NewWatcher(nil, "/tmp/test.yaml", store, nil, nil) w.muHistory.Lock() w.history = []*Config{cfg1, cfg2} @@ -177,7 +176,7 @@ func TestWatcher_Start(t *testing.T) { t.Run("successful start with valid dir", func(t *testing.T) { t.Parallel() tmpFile := createTempConfigFile(t) - w := NewWatcher(slog.Default(), tmpFile, nil, nil, nil, nil) + w := NewWatcher(slog.Default(), tmpFile, nil, nil, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -193,7 +192,7 @@ func TestWatcher_Start(t *testing.T) { tmpDir := t.TempDir() nonexistentPath := filepath.Join(tmpDir, "nonexistent.yaml") - w := NewWatcher(slog.Default(), nonexistentPath, nil, nil, nil, nil) + w := NewWatcher(slog.Default(), nonexistentPath, nil, nil, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -210,7 +209,7 @@ func TestWatcher_isRelevant(t *testing.T) { t.Parallel() tmpFile := createTempConfigFile(t) - w := NewWatcher(slog.Default(), tmpFile, nil, nil, nil, nil) + w := NewWatcher(slog.Default(), tmpFile, nil, nil, nil) tests := []struct { name string @@ -267,7 +266,7 @@ func TestWatcher_isRelevant(t *testing.T) { func TestWatcher_AuditLog(t *testing.T) { t.Parallel() - w := NewWatcher(slog.Default(), "/tmp/test.yaml", nil, nil, nil, nil) + w := NewWatcher(slog.Default(), "/tmp/test.yaml", nil, nil, nil) // Initially empty require.Empty(t, w.AuditLog()) @@ -291,7 +290,7 @@ func TestWatcher_AuditLog(t *testing.T) { func TestWatcher_History(t *testing.T) { t.Parallel() - w := NewWatcher(slog.Default(), "/tmp/test.yaml", nil, nil, nil, nil) + w := NewWatcher(slog.Default(), "/tmp/test.yaml", nil, nil, nil) // Initially empty require.Empty(t, w.History()) @@ -314,7 +313,7 @@ func TestWatcher_History(t *testing.T) { func TestWatcher_Close(t *testing.T) { t.Parallel() - w := NewWatcher(slog.Default(), "/tmp/test.yaml", nil, nil, nil, nil) + w := NewWatcher(slog.Default(), "/tmp/test.yaml", nil, nil, nil) // First close should succeed err := w.Close() diff --git a/internal/gateway/AGENTS.md b/internal/gateway/AGENTS.md index 2d353734..7d820acb 100644 --- a/internal/gateway/AGENTS.md +++ b/internal/gateway/AGENTS.md @@ -58,7 +58,7 @@ testutil/ # WebSocket mock helpers for tests **GatewayAPI (api.go)** - HTTP REST endpoints for session management alongside WebSocket -- Auth via `security.Authenticator` (JWT + API key) +- Auth via `security.Authenticator` (API key + Bot ID header) - ListSessions, GetSession, TerminateSession handlers **Backpressure** diff --git a/internal/gateway/api_test.go b/internal/gateway/api_test.go index 65603881..3cd0655e 100644 --- a/internal/gateway/api_test.go +++ b/internal/gateway/api_test.go @@ -175,7 +175,7 @@ func (m *mockTurnsStore) DeleteExpiredTurns(ctx context.Context, cutoff time.Tim func newTestAuth(t *testing.T) *security.Authenticator { t.Helper() - return security.NewAuthenticator(&config.SecurityConfig{}, nil) + return security.NewAuthenticator(&config.SecurityConfig{}) } func newTestAPI(t *testing.T, sm *mockAPISM, bridge *mockAPIBridge) *GatewayAPI { diff --git a/internal/gateway/conn.go b/internal/gateway/conn.go index 29ac6452..75f7c799 100644 --- a/internal/gateway/conn.go +++ b/internal/gateway/conn.go @@ -43,7 +43,7 @@ type Conn struct { sessionID string userID string - botID string // SEC-007: bot isolation tag from JWT + botID string // SEC-007: bot isolation tag from X-Bot-ID header or init envelope // pendingAuth defers authentication to the init envelope (browser WS clients). pendingAuth bool @@ -265,41 +265,25 @@ func (c *Conn) performInit(handler *Handler) error { // Authenticate via init envelope if HTTP-level auth was deferred. // Browser WebSocket clients cannot send custom headers, so auth is deferred // to the first init message (pendingAuth is set when HandleHTTP finds no API key). - didDeferredAuth := false if c.pendingAuth { if initData.Auth.Token == "" { c.sendInitError(events.ErrCodeUnauthorized, "authentication required") return fmt.Errorf("deferred auth: no token in init envelope") } - uid, ok := handler.auth.AuthenticateKey(context.TODO(), initData.Auth.Token) + uid, ok := handler.auth.AuthenticateKey(context.Background(), initData.Auth.Token) if !ok { c.sendInitError(events.ErrCodeUnauthorized, "invalid token") return fmt.Errorf("deferred auth: invalid token") } c.userID = uid c.pendingAuth = false - didDeferredAuth = true } - // Validate JWT token from init envelope (if provided and validator is configured). - // Skip if we completed deferred auth — the token was already validated as an API key, - // not a JWT. JWT validation requires an ES256-signed token with standard claims. - if initData.Auth.Token != "" && handler.jwtValidator != nil && !didDeferredAuth { - claims, err := handler.jwtValidator.Validate(initData.Auth.Token) - if err != nil { - c.log.Warn("gateway: init JWT validation failed", "session_id", c.sessionID, "err", err) - c.sendInitError(events.ErrCodeUnauthorized, "invalid token") - metrics.GatewayErrorsTotal.WithLabelValues(string(events.ErrCodeUnauthorized)).Inc() - return fmt.Errorf("jwt validation: %w", err) - } - // Bind user_id from JWT subject claim (overrides HTTP auth userID for session ownership). - if claims.Subject != "" { - c.userID = claims.Subject - } - // SEC-007: bind bot_id for multi-bot isolation. - if claims.BotID != "" { - c.botID = claims.BotID - } + // Extract botID from init envelope for deferred-auth clients (browser WS + // clients that cannot send custom headers). Non-deferred clients already + // have botID set from X-Bot-ID header during WS upgrade (hub.go). + if c.botID == "" && initData.Auth.BotID != "" { + c.botID = initData.Auth.BotID } // Resolve work dir: use client-provided value or default from config. diff --git a/internal/gateway/conn_test.go b/internal/gateway/conn_test.go index 44586dee..9dad81ca 100644 --- a/internal/gateway/conn_test.go +++ b/internal/gateway/conn_test.go @@ -2,9 +2,6 @@ package gateway import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "errors" "log/slog" "net/http" @@ -19,7 +16,6 @@ import ( "github.com/stretchr/testify/require" "github.com/hrygo/hotplex/internal/config" - "github.com/hrygo/hotplex/internal/security" "github.com/hrygo/hotplex/internal/session" "github.com/hrygo/hotplex/internal/worker" "github.com/hrygo/hotplex/internal/worker/noop" @@ -518,16 +514,12 @@ func (m *mockSessionStoreForBotID) Close() error { return args.Error(0) } -// makeInitEnvelope builds a init Envelope for the given session, workerType, and optional JWT token. -func makeInitEnvelope(sessionID, workerType, token string) []byte { +func makeInitEnvelope(sessionID, workerType string) []byte { data := map[string]any{ "version": events.Version, "worker_type": workerType, "session_id": sessionID, } - if token != "" { - data["auth"] = map[string]any{"token": token} - } env := events.NewEnvelope(aep.NewID(), sessionID, 1, events.Init, data) env.SessionID = sessionID raw, _ := aep.EncodeJSON(env) @@ -580,14 +572,6 @@ var expandedSafeTestWorkDir = func() string { return expanded }() -// newECDSAKey generates a fresh P-256 ECDSA key pair for ES256 JWT signing in tests. -func newECDSAKey(t *testing.T) *ecdsa.PrivateKey { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - return key -} - // TestBotIDIsolation_CreateMismatch tests that creating a new session with bot_id=bot_001 // and then resuming it with bot_id=bot_002 is rejected with ErrCodeUnauthorized. // This is the SEC-007 cross-bot access rejection at resume time. @@ -601,21 +585,6 @@ func TestBotIDIsolation_CreateMismatch(t *testing.T) { // Derive the server session ID using the same algorithm as conn.go:DeriveSessionKey. derivedSID := session.DeriveSessionKey("alice", worker.WorkerType(workerType), sessionIDConst, expandedSafeTestWorkDir) - // Build a JWT token for bot_alice using ES256 (ECDSA P-256). - jwtKey := newECDSAKey(t) - jwtVal := security.NewJWTValidator(jwtKey, "") - tokenAlice, err := jwtVal.GenerateTokenWithClaims(&security.JWTClaims{ - UserID: "alice", - BotID: botAlice, - }) - require.NoError(t, err) - - tokenBob, err := jwtVal.GenerateTokenWithClaims(&security.JWTClaims{ - UserID: "alice", - BotID: botBob, - }) - require.NoError(t, err) - // Phase 1: client A connects with bot_alice token and creates a session. store1 := new(mockSessionStoreForBotID) store1.Test(t) @@ -636,6 +605,8 @@ func TestBotIDIsolation_CreateMismatch(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { mgr1.Close() }) + handler1 := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h1, SM: mgr1}) + var serverConn1 *websocket.Conn var mu1 sync.Mutex server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -647,8 +618,7 @@ func TestBotIDIsolation_CreateMismatch(t *testing.T) { mu1.Unlock() go func() { c := newBotIDTestConn(h1, conn, derivedSID, "alice", botAlice) - h := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h1, SM: mgr1, JWTValidator: jwtVal}) - c.ReadPump(h) + c.ReadPump(handler1) }() })) t.Cleanup(server1.Close) @@ -666,7 +636,7 @@ func TestBotIDIsolation_CreateMismatch(t *testing.T) { }, 2*time.Second, 10*time.Millisecond) // Client A sends init with bot_alice token → should succeed (new session). - init1 := makeInitEnvelope(sessionIDConst, workerType, tokenAlice) + init1 := makeInitEnvelope(sessionIDConst, workerType) resp1, err := sendWSInit(client1, init1) require.NoError(t, err) require.Contains(t, string(resp1), `"type":"init_ack"`, "bot_alice create should succeed") @@ -701,6 +671,8 @@ func TestBotIDIsolation_CreateMismatch(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { mgr2.Close() }) + handler2 := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h2, SM: mgr2}) + var serverConn2 *websocket.Conn var mu2 sync.Mutex server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -712,8 +684,7 @@ func TestBotIDIsolation_CreateMismatch(t *testing.T) { mu2.Unlock() go func() { c := newBotIDTestConn(h2, conn, derivedSID, "alice", botBob) - h := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h2, SM: mgr2, JWTValidator: jwtVal}) - c.ReadPump(h) + c.ReadPump(handler2) }() })) t.Cleanup(server2.Close) @@ -730,7 +701,7 @@ func TestBotIDIsolation_CreateMismatch(t *testing.T) { }, 2*time.Second, 10*time.Millisecond) // Client B sends init with bot_bob token for the same session → should be rejected. - init2 := makeInitEnvelope(sessionIDConst, workerType, tokenBob) + init2 := makeInitEnvelope(sessionIDConst, workerType) resp2, err := sendWSInit(client2, init2) require.NoError(t, err) require.Contains(t, string(resp2), `"type":"init_ack"`) // init_ack is always sent, but contains error @@ -746,14 +717,6 @@ func TestBotIDIsolation_MatchAllowed(t *testing.T) { ) derivedSID := session.DeriveSessionKey("user1", worker.WorkerType(workerType), sessionIDConst, expandedSafeTestWorkDir) - jwtKey := newECDSAKey(t) - jwtVal := security.NewJWTValidator(jwtKey, "") - token, err := jwtVal.GenerateTokenWithClaims(&security.JWTClaims{ - UserID: "user1", - BotID: botID, - }) - require.NoError(t, err) - store := new(mockSessionStoreForBotID) store.Test(t) store.On("Close").Return(nil).Maybe() @@ -775,6 +738,7 @@ func TestBotIDIsolation_MatchAllowed(t *testing.T) { mgr, err := session.NewManager(context.Background(), slog.Default(), cfg, nil, store) require.NoError(t, err) t.Cleanup(func() { mgr.Close() }) + handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: hubForTest, SM: mgr}) var serverConn *websocket.Conn var mu sync.Mutex @@ -787,7 +751,6 @@ func TestBotIDIsolation_MatchAllowed(t *testing.T) { mu.Unlock() go func() { c := newBotIDTestConn(hubForTest, conn, derivedSID, "user1", botID) - handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: hubForTest, SM: mgr, JWTValidator: jwtVal}) c.ReadPump(handler) }() })) @@ -804,7 +767,7 @@ func TestBotIDIsolation_MatchAllowed(t *testing.T) { return ok }, 2*time.Second, 10*time.Millisecond) - init := makeInitEnvelope(sessionIDConst, workerType, token) + init := makeInitEnvelope(sessionIDConst, workerType) resp, err := sendWSInit(client, init) require.NoError(t, err) require.Contains(t, string(resp), `"type":"init_ack"`) @@ -828,9 +791,6 @@ func TestUserIDOwnership_MismatchRejected(t *testing.T) { // to simulate a key collision or direct UUID lookup scenario. derivedSIDBob := session.DeriveSessionKey("bob", worker.WorkerType(workerType), sessionIDConst, expandedSafeTestWorkDir) - jwtKey := newECDSAKey(t) - jwtVal := security.NewJWTValidator(jwtKey, "") - // Session exists, owned by "alice". existingSession := &session.SessionInfo{ ID: derivedSIDAlice, @@ -841,11 +801,6 @@ func TestUserIDOwnership_MismatchRejected(t *testing.T) { } // "bob" tries to reconnect to alice's session. - tokenBob, err := jwtVal.GenerateTokenWithClaims(&security.JWTClaims{ - UserID: "bob", - BotID: botID, // same bot_id — should not bypass user check - }) - require.NoError(t, err) store := new(mockSessionStoreForBotID) store.Test(t) @@ -861,6 +816,7 @@ func TestUserIDOwnership_MismatchRejected(t *testing.T) { mgr, err := session.NewManager(context.Background(), slog.Default(), cfg, nil, store) require.NoError(t, err) t.Cleanup(func() { mgr.Close() }) + handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: hubForTest, SM: mgr}) var serverConn *websocket.Conn var mu sync.Mutex @@ -874,7 +830,6 @@ func TestUserIDOwnership_MismatchRejected(t *testing.T) { go func() { // Bob connects, same bot_id as alice's session. c := newBotIDTestConn(hubForTest, conn, derivedSIDBob, "bob", botID) - handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: hubForTest, SM: mgr, JWTValidator: jwtVal}) c.ReadPump(handler) }() })) @@ -892,7 +847,7 @@ func TestUserIDOwnership_MismatchRejected(t *testing.T) { }, 2*time.Second, 10*time.Millisecond) // Bob sends init to reconnect to alice's session → should be rejected. - initMsg := makeInitEnvelope(sessionIDConst, workerType, tokenBob) + initMsg := makeInitEnvelope(sessionIDConst, workerType) resp, err := sendWSInit(client, initMsg) require.NoError(t, err) require.Contains(t, string(resp), `"type":"init_ack"`) @@ -909,14 +864,6 @@ func TestUserIDOwnership_MatchAllowed(t *testing.T) { ) derivedSID := session.DeriveSessionKey("alice", worker.WorkerType(workerType), sessionIDConst, expandedSafeTestWorkDir) - jwtKey := newECDSAKey(t) - jwtVal := security.NewJWTValidator(jwtKey, "") - token, err := jwtVal.GenerateTokenWithClaims(&security.JWTClaims{ - UserID: "alice", - BotID: botID, - }) - require.NoError(t, err) - existingSession := &session.SessionInfo{ ID: derivedSID, UserID: "alice", @@ -939,6 +886,7 @@ func TestUserIDOwnership_MatchAllowed(t *testing.T) { mgr, err := session.NewManager(context.Background(), slog.Default(), cfg, nil, store) require.NoError(t, err) t.Cleanup(func() { mgr.Close() }) + handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: hubForTest, SM: mgr}) var serverConn *websocket.Conn var mu sync.Mutex @@ -951,7 +899,6 @@ func TestUserIDOwnership_MatchAllowed(t *testing.T) { mu.Unlock() go func() { c := newBotIDTestConn(hubForTest, conn, derivedSID, "alice", botID) - handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: hubForTest, SM: mgr, JWTValidator: jwtVal}) c.ReadPump(handler) }() })) @@ -968,7 +915,7 @@ func TestUserIDOwnership_MatchAllowed(t *testing.T) { return ok }, 2*time.Second, 10*time.Millisecond) - initMsg := makeInitEnvelope(sessionIDConst, workerType, token) + initMsg := makeInitEnvelope(sessionIDConst, workerType) resp, err := sendWSInit(client, initMsg) require.NoError(t, err) require.Contains(t, string(resp), `"type":"init_ack"`) @@ -976,7 +923,6 @@ func TestUserIDOwnership_MatchAllowed(t *testing.T) { } // TestUserIDOwnership_EmptyConnectionUserID_Allowed tests that when the connection has -// no userID (anonymous, no JWT) but the session is owned by a named user, the SEC-008 // check is bypassed — allowing anonymous reconnects to named-user sessions. // This is intentional: either side being empty means ownership cannot be verified, // so the check is skipped (backward compatibility with anonymous access). @@ -1023,7 +969,6 @@ func TestUserIDOwnership_EmptyConnectionUserID_Allowed(t *testing.T) { serverConn = conn mu.Unlock() go func() { - // Empty userID (no JWT) — SEC-008 check should be bypassed. c := newBotIDTestConn(hubForTest, conn, derivedSIDEmpty, "", botID) handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: hubForTest, SM: mgr}) c.ReadPump(handler) @@ -1043,7 +988,7 @@ func TestUserIDOwnership_EmptyConnectionUserID_Allowed(t *testing.T) { }, 2*time.Second, 10*time.Millisecond) // No auth token → empty userID → SEC-008 bypassed → should succeed. - initMsg := makeInitEnvelope(sessionIDConst, workerType, "") + initMsg := makeInitEnvelope(sessionIDConst, workerType) resp, err := sendWSInit(client, initMsg) require.NoError(t, err) require.Contains(t, string(resp), `"type":"init_ack"`) @@ -1057,10 +1002,8 @@ func TestBotIDIsolation_EmptyBotIDAllowed(t *testing.T) { sessionIDConst = "sess_no_bot" workerType = "claude-code" ) - // When no JWT is provided, c.userID defaults to "anon" (from newBotIDTestConn). derivedSID := session.DeriveSessionKey("anon", worker.WorkerType(workerType), sessionIDConst, expandedSafeTestWorkDir) - // No JWT token (empty botID scenario). store := new(mockSessionStoreForBotID) store.Test(t) store.On("Close").Return(nil).Maybe() @@ -1107,7 +1050,7 @@ func TestBotIDIsolation_EmptyBotIDAllowed(t *testing.T) { }, 2*time.Second, 10*time.Millisecond) // No auth token → empty bot_id → should succeed. - init := makeInitEnvelope(sessionIDConst, workerType, "") + init := makeInitEnvelope(sessionIDConst, workerType) resp, err := sendWSInit(client, init) require.NoError(t, err) require.Contains(t, string(resp), `"type":"init_ack"`) @@ -1124,14 +1067,6 @@ func TestBotIDIsolation_NewSessionStoresBotID(t *testing.T) { ) derivedSID := session.DeriveSessionKey("user1", worker.WorkerType(workerType), sessionIDConst, expandedSafeTestWorkDir) - jwtKey := newECDSAKey(t) - jwtVal := security.NewJWTValidator(jwtKey, "") - token, err := jwtVal.GenerateTokenWithClaims(&security.JWTClaims{ - UserID: "user1", - BotID: botID, - }) - require.NoError(t, err) - store := new(mockSessionStoreForBotID) store.Test(t) store.On("Close").Return(nil).Maybe() @@ -1152,6 +1087,7 @@ func TestBotIDIsolation_NewSessionStoresBotID(t *testing.T) { mgr, err := session.NewManager(context.Background(), slog.Default(), cfg, nil, store) require.NoError(t, err) t.Cleanup(func() { mgr.Close() }) + handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h, SM: mgr}) var serverConn *websocket.Conn var mu sync.Mutex @@ -1164,7 +1100,6 @@ func TestBotIDIsolation_NewSessionStoresBotID(t *testing.T) { mu.Unlock() go func() { c := newBotIDTestConn(h, conn, derivedSID, "user1", botID) - handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h, SM: mgr, JWTValidator: jwtVal}) c.ReadPump(handler) }() })) @@ -1181,7 +1116,7 @@ func TestBotIDIsolation_NewSessionStoresBotID(t *testing.T) { return ok }, 2*time.Second, 10*time.Millisecond) - init := makeInitEnvelope(sessionIDConst, workerType, token) + init := makeInitEnvelope(sessionIDConst, workerType) resp, err := sendWSInit(client, init) require.NoError(t, err) require.Contains(t, string(resp), `"type":"init_ack"`) diff --git a/internal/gateway/deps.go b/internal/gateway/deps.go index 2170bc26..b1bdd0bb 100644 --- a/internal/gateway/deps.go +++ b/internal/gateway/deps.go @@ -14,7 +14,6 @@ type HandlerDeps struct { Hub *Hub SM SessionManager Auth *security.Authenticator - JWTValidator *security.JWTValidator Bridge *Bridge SkillsLocator SkillsLocator } diff --git a/internal/gateway/handler.go b/internal/gateway/handler.go index b21520e6..a260d3f4 100644 --- a/internal/gateway/handler.go +++ b/internal/gateway/handler.go @@ -26,7 +26,6 @@ type Handler struct { hub *Hub sm SessionManager auth *security.Authenticator - jwtValidator *security.JWTValidator bridge *Bridge skillsLocator SkillsLocator } @@ -44,7 +43,6 @@ func NewHandler(deps HandlerDeps) *Handler { hub: deps.Hub, sm: deps.SM, auth: deps.Auth, - jwtValidator: deps.JWTValidator, bridge: deps.Bridge, skillsLocator: deps.SkillsLocator, } diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go index 85d58855..db3a808e 100644 --- a/internal/gateway/hub.go +++ b/internal/gateway/hub.go @@ -355,7 +355,7 @@ func (h *Hub) HandleHTTP( return } userID = uid - botID = auth.BotIDFromRequest(r) + botID = security.BotIDFromRequest(r) } else { // No key at HTTP level — defer to init envelope auth (browser WS clients). pendingAuth = true diff --git a/internal/gateway/hub_test.go b/internal/gateway/hub_test.go index fd709075..d9f0f310 100644 --- a/internal/gateway/hub_test.go +++ b/internal/gateway/hub_test.go @@ -1036,7 +1036,7 @@ func TestHub_HandleHTTP_Success(t *testing.T) { cfg.Security.APIKeys = []string{"test-api-key"} // require this key cfg.Security.AllowedOrigins = []string{"*"} - auth := security.NewAuthenticator(&cfg.Security, nil) + auth := security.NewAuthenticator(&cfg.Security) h := newTestHub(t) handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h}) bridge := NewBridge(BridgeDeps{Log: slog.Default(), Hub: h}) @@ -1067,7 +1067,7 @@ func TestHub_HandleHTTP_DeferredAuth(t *testing.T) { cfg.Security.APIKeys = []string{"secret-key"} // require this key cfg.Security.AllowedOrigins = []string{"*"} - auth := security.NewAuthenticator(&cfg.Security, nil) + auth := security.NewAuthenticator(&cfg.Security) h := newTestHub(t) handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h}) bridge := NewBridge(BridgeDeps{Log: slog.Default(), Hub: h}) @@ -1090,7 +1090,7 @@ func TestHub_HandleHTTP_WithSessionID(t *testing.T) { cfg.Security.APIKeys = []string{"test-key"} cfg.Security.AllowedOrigins = []string{"*"} - auth := security.NewAuthenticator(&cfg.Security, nil) + auth := security.NewAuthenticator(&cfg.Security) h := newTestHub(t) handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h}) bridge := NewBridge(BridgeDeps{Log: slog.Default(), Hub: h}) @@ -1122,7 +1122,7 @@ func TestHub_HandleHTTP_GeneratesSessionID(t *testing.T) { cfg.Security.APIKeys = []string{"test-key"} cfg.Security.AllowedOrigins = []string{"*"} - auth := security.NewAuthenticator(&cfg.Security, nil) + auth := security.NewAuthenticator(&cfg.Security) h := newTestHub(t) handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h}) bridge := NewBridge(BridgeDeps{Log: slog.Default(), Hub: h}) @@ -1155,7 +1155,7 @@ func TestHub_HandleHTTP_RejectsInvalidAPIKey(t *testing.T) { cfg.Security.APIKeys = []string{"correct-key"} cfg.Security.AllowedOrigins = []string{"*"} - auth := security.NewAuthenticator(&cfg.Security, nil) + auth := security.NewAuthenticator(&cfg.Security) h := newTestHub(t) handler := NewHandler(HandlerDeps{Log: slog.Default(), Hub: h}) bridge := NewBridge(BridgeDeps{Log: slog.Default(), Hub: h}) diff --git a/internal/gateway/init.go b/internal/gateway/init.go index 5f132d15..c18cbe29 100644 --- a/internal/gateway/init.go +++ b/internal/gateway/init.go @@ -28,6 +28,7 @@ type InitData struct { // InitAuth carries authentication data embedded in the init envelope. type InitAuth struct { Token string `json:"token,omitempty"` + BotID string `json:"bot_id,omitempty"` } // InitConfig carries per-session configuration. @@ -148,6 +149,9 @@ func ValidateInit(env *events.Envelope) (InitData, *InitError) { if token, ok := authData["token"].(string); ok { auth.Token = token } + if bid, ok := authData["bot_id"].(string); ok { + auth.BotID = bid + } } cfg := InitConfig{} diff --git a/internal/security/AGENTS.md b/internal/security/AGENTS.md index 93694bba..44ac1e92 100644 --- a/internal/security/AGENTS.md +++ b/internal/security/AGENTS.md @@ -1,14 +1,13 @@ # Security Package ## OVERVIEW -Security validation layer: JWT/API auth, SSRF protection, safe path resolution, env isolation, command/tool/model allowlists, and rate/size limiting. +Security validation layer: API auth, SSRF protection, safe path resolution, env isolation, command/tool/model allowlists, and rate/size limiting. ## STRUCTURE | File | Purpose | |------|---------| -| `jwt.go` | ES256 JWT validation, JTI blacklist, API key comparison | -| `auth.go` | Authenticator combining JWT + API key | +| `auth.go` | Authenticator with API key validation + Bot ID extraction | | `ssrf.go` | URL validation against loopback/private/link-local CIDRs | | `path.go` | SafePathJoin 5-step validation, dangerous char detection | | `env.go` | Env var whitelists, sensitive prefix detection, nested agent stripping | @@ -22,8 +21,7 @@ Security validation layer: JWT/API auth, SSRF protection, safe path resolution, | Task | Location | |------|----------| -| JWT validation / JTI revoke | `jwt.go` — JWTValidator, jtiBlacklist | -| API key comparison | `jwt.go:245` — ValidateAPIKey (crypto/subtle.ConstantTimeCompare) | +| API key / Bot ID auth | `auth.go` — Authenticator, BotIDFromRequest | | URL SSRF check | `ssrf.go` — ValidateURL | | Safe file path | `path.go` — SafePathJoin | | Env var isolation | `env.go` + `env_builder.go` | @@ -34,9 +32,7 @@ Security validation layer: JWT/API auth, SSRF protection, safe path resolution, ## KEY PATTERNS -**JWT validation chain**: Parse → verify ES256 signature → validate exp/iat/nbf → check JTI blacklist → extract claims (UserID, Scopes, Role, BotID, SessionID) - -**JTI blacklist**: TTL-based in-memory cache with background sweep goroutine; JTI generated via crypto/rand +**Bot ID transport**: Client sends `X-Bot-ID` header or `bot_id` query param; server extracts via `BotIDFromRequest(r)`. Cross-bot access is rejected at session level. **SSRF protection**: Protocol check → hostname blocklist → IP prefix check → DNS resolution → blocked CIDRs (loopback 127.0.0.0/8, private 10/8 172.16/12 192.168/16, link-local 169.254.0.0/16 including AWS metadata 169.254.169.254, IPv6) @@ -48,8 +44,7 @@ Security validation layer: JWT/API auth, SSRF protection, safe path resolution, ## ANTI-PATTERNS -- JWT algorithms other than ES256 — reject all others -- math/rand for JTI/token generation — must use crypto/rand +- math/rand for token generation — must use crypto/rand - Shell execution — only claude/opencode binaries, no shell interpreters - Path separators in command names — "claude", "opencode" only, no "../opencode" - Cross-bot session access — bot_id must match session owner exactly diff --git a/internal/security/auth.go b/internal/security/auth.go index b592b250..41e9fc6c 100644 --- a/internal/security/auth.go +++ b/internal/security/auth.go @@ -3,9 +3,9 @@ package security import ( "context" + "crypto/subtle" "errors" "net/http" - "strings" "sync" "github.com/hrygo/hotplex/internal/config" @@ -15,25 +15,29 @@ import ( // that cannot send custom headers (CORS restrictions). const apiKeyQueryParam = "api_key" +// botIDHeader is the HTTP header for bot identity in multi-bot setups. +const botIDHeader = "X-Bot-ID" + +// botIDQueryParam is the query parameter fallback for browser WebSocket clients. +const botIDQueryParam = "bot_id" + // Authenticator validates API keys and user credentials. type Authenticator struct { - mu sync.RWMutex - cfg *config.SecurityConfig - validKey map[string]bool // set of valid API keys (hashed in production) - jwtValidator *JWTValidator // optional; set when JWT botID extraction is needed at HTTP level - keyResolver APIKeyResolver // optional; maps API keys to user identities. nil = "api_user" + mu sync.RWMutex + cfg *config.SecurityConfig + validKey map[string]bool // set of valid API keys (hashed in production) + keyResolver APIKeyResolver // optional; maps API keys to user identities. nil = "api_user" } -// NewAuthenticator creates a new authenticator. jwtValidator may be nil. -func NewAuthenticator(cfg *config.SecurityConfig, jwtValidator *JWTValidator) *Authenticator { +// NewAuthenticator creates a new authenticator. +func NewAuthenticator(cfg *config.SecurityConfig) *Authenticator { validKey := make(map[string]bool) for _, k := range cfg.APIKeys { validKey[k] = true } return &Authenticator{ - cfg: cfg, - validKey: validKey, - jwtValidator: jwtValidator, + cfg: cfg, + validKey: validKey, } } @@ -41,8 +45,7 @@ func NewAuthenticator(cfg *config.SecurityConfig, jwtValidator *JWTValidator) *A var ErrUnauthorized = errors.New("security: unauthorized") // AuthenticateRequest validates the request's API key. -// Returns the user ID, bot ID (from JWT BotID claim), and any error. -// botID may be empty when no JWT Bearer token is present. +// Returns the user ID, bot ID (from X-Bot-ID header or bot_id query param), and any error. func (a *Authenticator) AuthenticateRequest(r *http.Request) (string, string, error) { a.mu.RLock() header := a.cfg.APIKeyHeader @@ -60,20 +63,25 @@ func (a *Authenticator) AuthenticateRequest(r *http.Request) (string, string, er return "", "", ErrUnauthorized } - // Key lookup under RLock; map lookup is not constant-time - // but acceptable for API keys (small set, low timing sensitivity). - defer a.mu.RUnlock() - + // Dev mode: no keys configured — allow all. if len(a.validKey) == 0 { - // No keys configured — allow all (dev mode). - return "anonymous", a.BotIDFromRequest(r), nil + a.mu.RUnlock() + botID := BotIDFromRequest(r) + return "anonymous", botID, nil } - if !a.validKey[key] { + // Key lookup using constant-time comparison to prevent timing attacks. + if !a.authenticateKey(key) { + a.mu.RUnlock() return "", "", ErrUnauthorized } - return a.resolveUserID(r.Context(), key), a.BotIDFromRequest(r), nil + // Snapshot resolver under lock, then release before calling external resolver. + resolver := a.keyResolver + a.mu.RUnlock() + + botID := BotIDFromRequest(r) + return resolveUserIDWith(r.Context(), key, resolver), botID, nil } // ReloadKeys dynamically replaces the set of valid API keys. @@ -96,12 +104,28 @@ func (a *Authenticator) SetKeyResolver(r APIKeyResolver) { a.mu.Unlock() } +// authenticateKey performs constant-time comparison of the key against the valid key set. +// Caller must hold at least RLock. +func (a *Authenticator) authenticateKey(key string) bool { + for k := range a.validKey { + if subtle.ConstantTimeCompare([]byte(k), []byte(key)) == 1 { + return true + } + } + return false +} + // resolveUserID returns the user identity for a valid API key. // Checks the resolver first; falls back to "api_user" if no mapping exists. // Caller must hold at least RLock. func (a *Authenticator) resolveUserID(ctx context.Context, key string) string { - if a.keyResolver != nil { - if uid, ok := a.keyResolver.Resolve(ctx, key); ok { + return resolveUserIDWith(ctx, key, a.keyResolver) +} + +// resolveUserIDWith resolves user identity without holding any lock. +func resolveUserIDWith(ctx context.Context, key string, resolver APIKeyResolver) string { + if resolver != nil { + if uid, ok := resolver.Resolve(ctx, key); ok { return uid } } @@ -141,34 +165,26 @@ func (a *Authenticator) AuthenticateKey(ctx context.Context, key string) (string return "anonymous", true } - if !a.validKey[key] { + if !a.authenticateKey(key) { return "", false } return a.resolveUserID(ctx, key), true } -// BotIDFromRequest extracts the BotID claim from a JWT Bearer token in the Authorization header. -// Returns "" if no token is present or if extraction fails (fail-open). -func (a *Authenticator) BotIDFromRequest(r *http.Request) string { - if a.jwtValidator == nil { - return "" - } - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") { - return "" - } - tokenString := strings.TrimPrefix(auth, "Bearer ") - if tokenString == "" { - return "" - } - // SECURITY: Verify the token signature before extracting botID. - // We use the same ES256 validation as the full JWT check, but silently - // ignore errors (fail-open) since the API key is the primary auth gate. - claims, err := a.jwtValidator.Validate(tokenString) - if err != nil { - return "" - } - return claims.BotID +// BotIDFromRequest extracts the bot ID from X-Bot-ID header or bot_id query param. +// Returns "" if not provided (no bot isolation). +// +// Trust boundary: Bot ID is NOT cryptographically bound to the API key. +// Any authenticated client can specify any bot ID. This is acceptable because: +// 1. Bot ID determines routing behavior (which bot configuration to use), not authorization. +// 2. API key authentication already gates access at the connection level. +// 3. Cross-bot data isolation is enforced downstream by session key derivation. +// If API-key-to-bot-ID binding is required, implement a KeyBotBinding resolver. +func BotIDFromRequest(r *http.Request) string { + if v := r.Header.Get(botIDHeader); v != "" { + return v + } + return r.URL.Query().Get(botIDQueryParam) } // Middleware returns an HTTP middleware that enforces authentication. diff --git a/internal/security/auth_test.go b/internal/security/auth_test.go index 73895a5d..95c7440a 100644 --- a/internal/security/auth_test.go +++ b/internal/security/auth_test.go @@ -42,7 +42,7 @@ func TestNewAuthenticator(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - auth := NewAuthenticator(tt.cfg, nil) + auth := NewAuthenticator(tt.cfg) require.NotNil(t, auth) require.Equal(t, tt.want, len(auth.validKey)) }) @@ -112,7 +112,7 @@ func TestAuthenticateRequest(t *testing.T) { APIKeys: tt.apiKeys, APIKeyHeader: tt.headerName, } - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) req := httptest.NewRequest("GET", "/test", nil) if tt.requestKey != "" { @@ -136,55 +136,38 @@ func TestAuthenticateRequest(t *testing.T) { } } -// TestAuthenticateRequest_BotIDFromJWT tests that AuthenticateRequest extracts botID from a JWT -// Bearer token in the Authorization header when a JWTValidator is configured. -func TestAuthenticateRequest_BotIDFromJWT(t *testing.T) { +func TestBotIDFromRequest(t *testing.T) { t.Parallel() - // Set up API key auth + JWT validator. - apiKey := "secret-api-key" - jwtSecret := []byte("test-jwt-secret-123") - jwtVal := NewJWTValidator(jwtSecret, "") - cfg := &config.SecurityConfig{ - APIKeys: []string{apiKey}, - APIKeyHeader: "X-API-Key", - } - auth := NewAuthenticator(cfg, jwtVal) - tests := []struct { name string - apiKey string - jwtToken string + header string + query string wantBotID string - wantErr bool }{ { - name: "valid api key, JWT with bot_id", - apiKey: apiKey, - jwtToken: mustGenToken(jwtVal, "user1", "bot_001"), + name: "X-Bot-ID header", + header: "bot_001", + query: "", wantBotID: "bot_001", - wantErr: false, }, { - name: "valid api key, JWT with empty bot_id", - apiKey: apiKey, - jwtToken: mustGenToken(jwtVal, "user1", ""), - wantBotID: "", - wantErr: false, + name: "bot_id query param fallback", + header: "", + query: "bot_002", + wantBotID: "bot_002", }, { - name: "valid api key, no JWT token", - apiKey: apiKey, - jwtToken: "", - wantBotID: "", - wantErr: false, + name: "header takes precedence over query", + header: "bot_header", + query: "bot_query", + wantBotID: "bot_header", }, { - name: "valid api key, invalid JWT token", - apiKey: apiKey, - jwtToken: "not-a-valid-jwt", - wantBotID: "", // fail-open: botID silently empty, mismatch check deferred to performInit - wantErr: false, + name: "no bot id provided", + header: "", + query: "", + wantBotID: "", }, } @@ -193,66 +176,69 @@ func TestAuthenticateRequest_BotIDFromJWT(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - req := httptest.NewRequest("GET", "/test", nil) - req.Header.Set("X-API-Key", tt.apiKey) - if tt.jwtToken != "" { - req.Header.Set("Authorization", "Bearer "+tt.jwtToken) + url := "/test" + if tt.query != "" { + url += "?bot_id=" + tt.query } - - userID, botID, err := auth.AuthenticateRequest(req) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - require.Equal(t, "api_user", userID) - require.Equal(t, tt.wantBotID, botID) + req := httptest.NewRequest("GET", url, nil) + if tt.header != "" { + req.Header.Set("X-Bot-ID", tt.header) } + + botID := BotIDFromRequest(req) + require.Equal(t, tt.wantBotID, botID) }) } } -// mustGenToken is a test helper that generates a JWT token with the given userID and botID. -// Panics on error (only for test use). -func mustGenToken(v *JWTValidator, userID, botID string) string { - token, err := v.GenerateTokenWithClaims(&JWTClaims{ - UserID: userID, - BotID: botID, - }) - if err != nil { - panic("mustGenToken: " + err.Error()) +func TestAuthenticateRequest_BotIDFromRequest(t *testing.T) { + t.Parallel() + + apiKey := "secret-api-key" + cfg := &config.SecurityConfig{ + APIKeys: []string{apiKey}, + APIKeyHeader: "X-API-Key", } - return token -} + auth := NewAuthenticator(cfg) -// TestAuthenticateRequest_DevModeBotID tests that in dev mode (no API keys configured), -// botID is still extracted from the JWT token when the API key header is present. -func TestAuthenticateRequest_DevModeBotID(t *testing.T) { - t.Parallel() + t.Run("X-Bot-ID header", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("X-Bot-ID", "bot_001") + + userID, botID, err := auth.AuthenticateRequest(req) + require.NoError(t, err) + require.Equal(t, "api_user", userID) + require.Equal(t, "bot_001", botID) + }) - jwtSecret := []byte("dev-jwt-secret") - jwtVal := NewJWTValidator(jwtSecret, "") - cfg := &config.SecurityConfig{APIKeys: []string{}} // dev mode: no API keys - auth := NewAuthenticator(cfg, jwtVal) + t.Run("bot_id query param", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/test?bot_id=bot_002", nil) + req.Header.Set("X-API-Key", apiKey) - token := mustGenToken(jwtVal, "dev_user", "bot_dev") + _, botID, err := auth.AuthenticateRequest(req) + require.NoError(t, err) + require.Equal(t, "bot_002", botID) + }) - req := httptest.NewRequest("GET", "/test", nil) - // Dev mode still requires the API key header to be present. - req.Header.Set("X-API-Key", "any-value") - req.Header.Set("Authorization", "Bearer "+token) + t.Run("no bot id", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("X-API-Key", apiKey) - // In dev mode, any request with valid JWT is allowed and botID is extracted. - userID, botID, err := auth.AuthenticateRequest(req) - require.NoError(t, err) - require.Equal(t, "anonymous", userID) // dev mode: hard-coded user - require.Equal(t, "bot_dev", botID) + _, botID, err := auth.AuthenticateRequest(req) + require.NoError(t, err) + require.Empty(t, botID) + }) } func TestMiddleware(t *testing.T) { t.Parallel() cfg := &config.SecurityConfig{APIKeys: []string{"secret123"}} - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -302,23 +288,18 @@ func TestMiddleware(t *testing.T) { func TestMiddleware_DevMode(t *testing.T) { t.Parallel() - // Dev mode: no keys configured cfg := &config.SecurityConfig{APIKeys: []string{}} - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - // In dev mode (no keys configured), any request without API key still gets 401 - // because AuthenticateRequest checks if key header exists req := httptest.NewRequest("GET", "/protected", nil) rec := httptest.NewRecorder() auth.Middleware(handler).ServeHTTP(rec, req) - // Dev mode allows access with any key, but still requires the header - // Since no header is provided, it should be unauthorized require.Equal(t, http.StatusUnauthorized, rec.Code) } @@ -353,7 +334,6 @@ func TestClaimsFrom_NoClaims(t *testing.T) { func TestClaimsFrom_WrongType(t *testing.T) { t.Parallel() - // Context with wrong type value ctx := context.WithValue(context.Background(), claimsKey, "not-claims") claims, ok := ClaimsFrom(ctx) @@ -365,7 +345,7 @@ func TestReloadKeys(t *testing.T) { t.Parallel() cfg := &config.SecurityConfig{APIKeys: []string{"key1"}} - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) userID, ok := auth.AuthenticateKey(context.Background(), "key1") require.True(t, ok) @@ -385,7 +365,7 @@ func TestExtractAPIKey(t *testing.T) { t.Parallel() cfg := &config.SecurityConfig{APIKeys: []string{"test"}} - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) t.Run("from header", func(t *testing.T) { t.Parallel() @@ -426,7 +406,7 @@ func TestAuthenticateKey(t *testing.T) { t.Run("valid key", func(t *testing.T) { t.Parallel() - auth := NewAuthenticator(&config.SecurityConfig{APIKeys: []string{"secret"}}, nil) + auth := NewAuthenticator(&config.SecurityConfig{APIKeys: []string{"secret"}}) userID, ok := auth.AuthenticateKey(context.Background(), "secret") require.True(t, ok) require.Equal(t, "api_user", userID) @@ -434,14 +414,14 @@ func TestAuthenticateKey(t *testing.T) { t.Run("invalid key", func(t *testing.T) { t.Parallel() - auth := NewAuthenticator(&config.SecurityConfig{APIKeys: []string{"secret"}}, nil) + auth := NewAuthenticator(&config.SecurityConfig{APIKeys: []string{"secret"}}) _, ok := auth.AuthenticateKey(context.Background(), "wrong") require.False(t, ok) }) t.Run("dev mode", func(t *testing.T) { t.Parallel() - auth := NewAuthenticator(&config.SecurityConfig{APIKeys: []string{}}, nil) + auth := NewAuthenticator(&config.SecurityConfig{APIKeys: []string{}}) userID, ok := auth.AuthenticateKey(context.Background(), "anything") require.True(t, ok) require.Equal(t, "anonymous", userID) @@ -450,7 +430,6 @@ func TestAuthenticateKey(t *testing.T) { func TestRegisterCommand(t *testing.T) { // Do NOT use t.Parallel() — RegisterCommand mutates the global allowedCommands map. - t.Run("valid command", func(t *testing.T) { err := RegisterCommand("custom-worker") require.NoError(t, err) @@ -481,11 +460,10 @@ func TestAuthenticator_WithMapResolver(t *testing.T) { cfg := &config.SecurityConfig{ APIKeys: []string{"sk-alice", "sk-bob", "sk-orphan"}, } - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) auth.SetKeyResolver(NewMapResolver(map[string]string{ "sk-alice": "alice", "sk-bob": "bob", - // sk-orphan has no mapping → should fall back to "api_user" })) tests := []struct { @@ -512,9 +490,8 @@ func TestAuthenticator_SetKeyResolver_Nil(t *testing.T) { t.Parallel() cfg := &config.SecurityConfig{APIKeys: []string{"sk-test"}} - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) - // Set then clear resolver auth.SetKeyResolver(NewMapResolver(map[string]string{"sk-test": "mapped"})) uid, ok := auth.AuthenticateKey(context.Background(), "sk-test") require.True(t, ok) @@ -530,7 +507,7 @@ func TestAuthenticator_WithResolver_AuthenticateRequest(t *testing.T) { t.Parallel() cfg := &config.SecurityConfig{APIKeys: []string{"sk-alice"}} - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) auth.SetKeyResolver(NewMapResolver(map[string]string{"sk-alice": "alice"})) req := httptest.NewRequest("GET", "/test", nil) @@ -541,35 +518,30 @@ func TestAuthenticator_WithResolver_AuthenticateRequest(t *testing.T) { require.Equal(t, "alice", userID) } -func TestAuthenticator_ResolverWithJWT(t *testing.T) { +func TestAuthenticator_ResolverWithBotIDHeader(t *testing.T) { t.Parallel() apiKey := "sk-alice" - jwtSecret := []byte("resolver-jwt-secret") - jwtVal := NewJWTValidator(jwtSecret, "") cfg := &config.SecurityConfig{APIKeys: []string{apiKey}} - auth := NewAuthenticator(cfg, jwtVal) + auth := NewAuthenticator(cfg) auth.SetKeyResolver(NewMapResolver(map[string]string{apiKey: "alice-resolved"})) - token := mustGenToken(jwtVal, "jwt-user", "bot-007") req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("X-API-Key", apiKey) - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Bot-ID", "bot-007") userID, botID, err := auth.AuthenticateRequest(req) require.NoError(t, err) require.Equal(t, "alice-resolved", userID, "resolver userID should override default") - require.Equal(t, "bot-007", botID, "JWT botID should still be extracted") + require.Equal(t, "bot-007", botID, "X-Bot-ID header should be extracted") } func TestAuthenticator_ChainResolver(t *testing.T) { t.Parallel() cfg := &config.SecurityConfig{APIKeys: []string{"sk-1", "sk-2"}} - auth := NewAuthenticator(cfg, nil) + auth := NewAuthenticator(cfg) - // Simulate: DB maps sk-1 to "db-user", config maps sk-1 to "config-user" - // ChainResolver tries DB first, so DB wins. dbResolver := NewMapResolver(map[string]string{"sk-1": "db-user"}) configResolver := NewMapResolver(map[string]string{"sk-1": "config-user", "sk-2": "config-only"}) auth.SetKeyResolver(NewChainResolver(dbResolver, configResolver)) diff --git a/internal/security/env.go b/internal/security/env.go index ed237491..81312397 100644 --- a/internal/security/env.go +++ b/internal/security/env.go @@ -6,13 +6,12 @@ import "strings" // Separate from worker blocklists since BuildEnv must pass HOME/PATH/USER // through to worker processes. var cliProtectedVars = map[string]bool{ - "HOME": true, - "PATH": true, - "USER": true, - "SHELL": true, - "CLAUDECODE": true, - "GATEWAY_ADDR": true, - "GATEWAY_TOKEN": true, + "HOME": true, + "PATH": true, + "USER": true, + "SHELL": true, + "CLAUDECODE": true, + "GATEWAY_ADDR": true, } // IsProtected reports whether an environment variable key should not be diff --git a/internal/security/jwt.go b/internal/security/jwt.go deleted file mode 100644 index fbab4422..00000000 --- a/internal/security/jwt.go +++ /dev/null @@ -1,330 +0,0 @@ -// Package security provides authentication and authorization for the gateway. -package security - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/hkdf" - "crypto/sha256" - "errors" - "fmt" - "math/big" - "slices" - "strings" - "sync" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" -) - -// ErrTokenRevoked is returned when a token's jti is on the blacklist. -var ErrTokenRevoked = errors.New("security: token revoked") - -// ErrInvalidAudience is returned when the JWT audience claim is invalid. -var ErrInvalidAudience = errors.New("security: invalid audience") - -// JWTValidator validates and parses JWT tokens. -// Only ES256 (ECDSA P-256) signing method is accepted, per security design. -type JWTValidator struct { - secret any // *ecdsa.PrivateKey or []byte (raw secret) - audience string - blacklist *jtiBlacklist -} - -// NewJWTValidator creates a JWT validator. -func NewJWTValidator(secret any, audience string) *JWTValidator { - return &JWTValidator{ - secret: secret, - audience: audience, - blacklist: newJTIBlacklist(), - } -} - -// JWTClaims represents the JWT claims structure per RFC 7519 and HotPlex design. -type JWTClaims struct { - jwt.RegisteredClaims - - // HotPlex-specific claims - UserID string `json:"user_id,omitempty"` - Scopes []string `json:"scopes,omitempty"` - Role string `json:"role,omitempty"` - BotID string `json:"bot_id,omitempty"` - SessionID string `json:"session_id,omitempty"` -} - -// Validate parses and validates a JWT token string. -func (v *JWTValidator) Validate(tokenString string) (*JWTClaims, error) { - tokenString = strings.TrimSpace(tokenString) - if tokenString == "" { - return nil, ErrUnauthorized - } - - tokenString = strings.TrimPrefix(tokenString, "Bearer ") - - token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) { - switch token.Method.Alg() { - case "ES256": - switch s := v.secret.(type) { - case *ecdsa.PrivateKey: - return s.Public(), nil - case []byte: - return deriveECDSAP256Key(s).Public(), nil - default: - return nil, fmt.Errorf("security: invalid secret type for ES256: %T", v.secret) - } - default: - return nil, fmt.Errorf("security: rejected signing method: %v (only ES256 is allowed)", token.Header["alg"]) - } - }) - - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrUnauthorized, err) - } - - claims, ok := token.Claims.(*JWTClaims) - if !ok || !token.Valid { - return nil, ErrUnauthorized - } - - if claims.ExpiresAt != nil && claims.ExpiresAt.Time.Before(time.Now()) { - return nil, fmt.Errorf("%w: token expired", ErrUnauthorized) - } - - if v.audience != "" && !v.hasAudience(claims.Audience) { - return nil, ErrInvalidAudience - } - - if claims.ID != "" && v.blacklist.isRevoked(claims.ID) { - return nil, ErrTokenRevoked - } - - return claims, nil -} - -func (v *JWTValidator) hasAudience(aud any) bool { - if aud == nil { - return false - } - switch s := aud.(type) { - case string: - return s == v.audience - case jwt.ClaimStrings: - return slices.Contains(s, v.audience) - case []string: - return slices.Contains(s, v.audience) - case []any: - for _, item := range s { - if str, ok := item.(string); ok && str == v.audience { - return true - } - } - } - return false -} - -// GenerateToken generates a new JWT token for the given user. -func (v *JWTValidator) GenerateToken(userID string, scopes []string, ttl time.Duration) (string, error) { - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: userID, - ID: mustGenerateJTI(), - }, - UserID: userID, - Scopes: scopes, - } - if v.audience != "" { - claims.Audience = jwt.ClaimStrings{v.audience} - } - return v.GenerateTokenWithClaims(claims) -} - -// GenerateTokenWithClaims generates a JWT token with the given claims using ES256. -func (v *JWTValidator) GenerateTokenWithClaims(claims *JWTClaims) (string, error) { - signingKey, err := v.resolveSigningKey() - if err != nil { - return "", err - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - return token.SignedString(signingKey) -} - -// resolveSigningKey returns the ES256 signing key for the configured secret. -func (v *JWTValidator) resolveSigningKey() (any, error) { - switch secret := v.secret.(type) { - case *ecdsa.PrivateKey: - return secret, nil - case []byte: - return deriveECDSAP256Key(secret), nil - default: - return nil, errors.New("security: invalid secret type") - } -} - -// deriveECDSAP256Key derives an ECDSA P-256 private key from a byte slice -// using HKDF (RFC 5869). The info parameter binds the derived key to the -// "hotplex-ecdsa-p256" context, preventing cross-protocol key reuse. -func deriveECDSAP256Key(secret []byte) *ecdsa.PrivateKey { - scalarBytes, err := hkdf.Key(sha256.New, secret, nil, "hotplex-ecdsa-p256", 32) - if err != nil { - panic("hkdf.Key: " + err.Error()) - } - s := new(big.Int).SetBytes(scalarBytes) - N := elliptic.P256().Params().N - s.Mod(s, new(big.Int).Sub(N, big.NewInt(1))) - s.Add(s, big.NewInt(1)) - x, y := elliptic.P256().ScalarBaseMult(s.Bytes()) //nolint:staticcheck // SA1019: must use deprecated scalar multiplication for deterministic ECDSA key derivation from seed - return &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, D: s} -} - -// GenerateTokenWithJTI generates a token and adds its jti to the blacklist. -func (v *JWTValidator) GenerateTokenWithJTI(userID string, scopes []string, ttl, jtiTTL time.Duration) (string, string, error) { - jti := mustGenerateJTI() - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: userID, - ID: jti, - }, - UserID: userID, - Scopes: scopes, - } - if v.audience != "" { - claims.Audience = jwt.ClaimStrings{v.audience} - } - var method jwt.SigningMethod - var signingKey any - signingKey, err := v.resolveSigningKey() - if err != nil { - return "", "", err - } - method = jwt.SigningMethodES256 - token := jwt.NewWithClaims(method, claims) - signed, err := token.SignedString(signingKey) - if err != nil { - return "", "", err - } - blacklistTTL := jtiTTL - if blacklistTTL == 0 { - blacklistTTL = ttl * 2 - if blacklistTTL == 0 { - blacklistTTL = 10 * time.Minute - } - } - v.blacklist.revoke(jti, blacklistTTL) - return signed, jti, nil -} - -// RevokeToken adds a jti to the blacklist with the given TTL. -func (v *JWTValidator) RevokeToken(jti string, ttl time.Duration) { - v.blacklist.revoke(jti, ttl) -} - -// IsRevoked checks if a jti is currently revoked. -func (v *JWTValidator) IsRevoked(jti string) bool { - return v.blacklist.isRevoked(jti) -} - -// Stop terminates the JTI blacklist sweep goroutine. Call during gateway shutdown. -func (v *JWTValidator) Stop() { - if v.blacklist != nil { - v.blacklist.Stop() - } -} - -// ─── JTI Blacklist ──────────────────────────────────────────────────────────── - -type jtiBlacklist struct { - entries sync.Map - stopCh chan struct{} - stopOnce sync.Once -} - -func newJTIBlacklist() *jtiBlacklist { - b := &jtiBlacklist{stopCh: make(chan struct{})} - go b.sweep(1 * time.Minute) - return b -} - -func (b *jtiBlacklist) revoke(jti string, ttl time.Duration) { - if jti == "" { - return - } - b.entries.Store(jti, time.Now().Add(ttl)) -} - -func (b *jtiBlacklist) isRevoked(jti string) bool { - if jti == "" { - return false - } - val, ok := b.entries.Load(jti) - if !ok { - return false - } - exp, ok := val.(time.Time) - if !ok { - return false - } - if time.Now().After(exp) { - b.entries.Delete(jti) - return false - } - return true -} - -func (b *jtiBlacklist) sweep(interval time.Duration) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-b.stopCh: - return - case <-ticker.C: - now := time.Now() - b.entries.Range(func(key, val any) bool { - if exp, ok := val.(time.Time); ok && now.After(exp) { - b.entries.Delete(key) - } - return true - }) - } - } -} - -func (b *jtiBlacklist) Stop() { - b.stopOnce.Do(func() { - close(b.stopCh) - }) -} - -// Size returns the approximate number of entries in the blacklist. -func (b *jtiBlacklist) Size() int { - count := 0 - b.entries.Range(func(_, _ any) bool { - count++ - return true - }) - return count -} - -// ─── JTI Generation ──────────────────────────────────────────────────────────── - -// GenerateJTI generates a cryptographically secure JWT ID. -func GenerateJTI() (string, error) { - return uuid.New().String(), nil -} - -func mustGenerateJTI() string { - jti, err := GenerateJTI() - if err != nil { - panic(fmt.Sprintf("security: failed to generate JTI: %v", err)) - } - return jti -} diff --git a/internal/security/jwt_test.go b/internal/security/jwt_test.go deleted file mode 100644 index bdb44bcf..00000000 --- a/internal/security/jwt_test.go +++ /dev/null @@ -1,769 +0,0 @@ -package security - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "fmt" - "sync" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/stretchr/testify/require" -) - -// ─── NewJWTValidator ──────────────────────────────────────────────────────────── - -func TestNewJWTValidator(t *testing.T) { - t.Parallel() - - t.Run("with ECDSA key", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - v := NewJWTValidator(key, "test-audience") - require.NotNil(t, v) - require.NotNil(t, v.blacklist) - require.Equal(t, "test-audience", v.audience) - }) - - t.Run("with HMAC secret", func(t *testing.T) { - t.Parallel() - secret := []byte("test-secret-key-32-bytes-long!!!") - - v := NewJWTValidator(secret, "test-audience") - require.NotNil(t, v) - require.NotNil(t, v.blacklist) - require.Equal(t, "test-audience", v.audience) - }) -} - -// ─── Validate ─────────────────────────────────────────────────────────────────── - -func TestValidate(t *testing.T) { - // NOT parallel — shared validator instance. - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err, "failed to generate ECDSA key") - - // Validator without audience requirement (audience is tested in TestHasAudience). - validator := NewJWTValidator(key, "") - defer validator.blacklist.Stop() - - tests := []struct { - name string - setupToken func() string - wantErr bool - errContains string - }{ - { - name: "valid token", - setupToken: func() string { - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: "user-123", - }, - UserID: "user-123", - Scopes: []string{"read", "write"}, - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - signed, _ := token.SignedString(key) - return signed - }, - wantErr: false, - }, - { - name: "empty token", - setupToken: func() string { - return "" - }, - wantErr: true, - errContains: "unauthorized", - }, - { - name: "whitespace only token", - setupToken: func() string { - return " " - }, - wantErr: true, - errContains: "unauthorized", - }, - { - name: "Bearer prefix stripped", - setupToken: func() string { - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: "user-123", - }, - UserID: "user-123", - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - signed, _ := token.SignedString(key) - return "Bearer " + signed - }, - wantErr: false, - }, - { - name: "expired token", - setupToken: func() string { - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), - Issuer: "hotplex", - Subject: "user-123", - }, - UserID: "user-123", - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - signed, _ := token.SignedString(key) - return signed - }, - wantErr: true, - errContains: "expired", - }, - { - name: "revoked token", - setupToken: func() string { - jti := mustGenerateJTI() - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: "user-123", - ID: jti, - }, - UserID: "user-123", - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - signed, _ := token.SignedString(key) - validator.RevokeToken(jti, 10*time.Minute) - return signed - }, - wantErr: true, - errContains: "revoked", - }, - { - name: "invalid signature", - setupToken: func() string { - // Create token with different key - wrongKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: "user-123", - }, - UserID: "user-123", - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - signed, _ := token.SignedString(wrongKey) - return signed - }, - wantErr: true, - errContains: "unauthorized", - }, - { - name: "wrong signing method HS256", - setupToken: func() string { - secret := []byte("test-secret") - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: "user-123", - }, - UserID: "user-123", - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, _ := token.SignedString(secret) - return signed - }, - wantErr: true, - errContains: "rejected signing method", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - tokenString := tt.setupToken() - claims, err := validator.Validate(tokenString) - if tt.wantErr { - require.Error(t, err) - if tt.errContains != "" { - require.Contains(t, err.Error(), tt.errContains) - } - require.Nil(t, claims) - } else { - require.NoError(t, err) - require.NotNil(t, claims) - } - }) - } -} - -// ─── hasAudience ──────────────────────────────────────────────────────────────── - -func TestHasAudience(t *testing.T) { - t.Parallel() - - validator := &JWTValidator{audience: "hotplex-gateway"} - - tests := []struct { - name string - aud any - expected bool - }{ - {"string match", "hotplex-gateway", true}, - {"string no match", "other-audience", false}, - {"string slice match", []string{"api", "hotplex-gateway", "web"}, true}, - {"string slice no match", []string{"api", "web"}, false}, - {"empty string slice", []string{}, false}, - {"any slice match", []any{"api", "hotplex-gateway"}, true}, - {"any slice no match", []any{"api", "web"}, false}, - {"ClaimStrings match", jwt.ClaimStrings{"api", "hotplex-gateway", "web"}, true}, - {"ClaimStrings no match", jwt.ClaimStrings{"api", "web"}, false}, - {"empty ClaimStrings", jwt.ClaimStrings{}, false}, - {"nil audience", nil, false}, - {"empty string", "", false}, - {"int audience (invalid type)", 123, false}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result := validator.hasAudience(tt.aud) - require.Equal(t, tt.expected, result) - }) - } -} - -// ─── GenerateToken ────────────────────────────────────────────────────────────── - -func TestGenerateToken(t *testing.T) { - t.Parallel() - - t.Run("with ECDSA key", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - audience := "test-audience" - validator := NewJWTValidator(key, audience) - defer validator.blacklist.Stop() - - token, err := validator.GenerateToken("user-123", []string{"read", "write"}, 1*time.Hour) - require.NoError(t, err) - require.NotEmpty(t, token) - - // GenerateToken sets Audience when validator has audience configured. - claims, err := validator.Validate(token) - require.NoError(t, err) - require.Equal(t, "user-123", claims.UserID) - require.Equal(t, []string{"read", "write"}, claims.Scopes) - require.Equal(t, "user-123", claims.Subject) - require.Equal(t, "hotplex", claims.Issuer) - require.NotEmpty(t, claims.ID) - require.Contains(t, claims.Audience, audience) - }) - - t.Run("with HMAC secret derives ES256 key for full round-trip", func(t *testing.T) { - t.Parallel() - secret := []byte("test-secret-key-32-bytes-long!!!") - - audience := "test-audience" - validator := NewJWTValidator(secret, audience) - defer validator.blacklist.Stop() - - token, err := validator.GenerateToken("user-456", []string{"admin"}, 30*time.Minute) - require.NoError(t, err) - require.NotEmpty(t, token) - - // HMAC secret is used to derive an ECDSA P-256 key; signing and - // validation both use ES256, so the round-trip succeeds. - claims, err := validator.Validate(token) - require.NoError(t, err) - require.Equal(t, "user-456", claims.UserID) - require.Contains(t, claims.Audience, audience) - }) -} - -// ─── GenerateTokenWithClaims ──────────────────────────────────────────────────── - -func TestGenerateTokenWithClaims(t *testing.T) { - t.Parallel() - - t.Run("with ECDSA key", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - validator := NewJWTValidator(key, "") - defer validator.blacklist.Stop() - - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "custom-issuer", - Subject: "user-789", - }, - UserID: "user-789", - Scopes: []string{"read", "write", "delete"}, - Role: "admin", - BotID: "bot-001", - SessionID: "sess-abc123", - } - - token, err := validator.GenerateTokenWithClaims(claims) - require.NoError(t, err) - require.NotEmpty(t, token) - - // Validate the generated token - parsedClaims, err := validator.Validate(token) - require.NoError(t, err) - require.Equal(t, "user-789", parsedClaims.UserID) - require.Equal(t, []string{"read", "write", "delete"}, parsedClaims.Scopes) - require.Equal(t, "admin", parsedClaims.Role) - require.Equal(t, "bot-001", parsedClaims.BotID) - require.Equal(t, "sess-abc123", parsedClaims.SessionID) - }) - - t.Run("with HMAC secret", func(t *testing.T) { - t.Parallel() - secret := []byte("test-secret-key-32-bytes-long!!!") - - validator := NewJWTValidator(secret, "test-audience") - defer validator.blacklist.Stop() - - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: "user-999", - }, - UserID: "user-999", - } - - token, err := validator.GenerateTokenWithClaims(claims) - require.NoError(t, err) - require.NotEmpty(t, token) - }) - - t.Run("invalid secret type", func(t *testing.T) { - t.Parallel() - validator := &JWTValidator{ - secret: "invalid-secret-type", - audience: "test", - blacklist: newJTIBlacklist(), - } - defer validator.blacklist.Stop() - - claims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - }, - } - - token, err := validator.GenerateTokenWithClaims(claims) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid secret type") - require.Empty(t, token) - }) -} - -// ─── GenerateTokenWithJTI ─────────────────────────────────────────────────────── - -func TestGenerateTokenWithJTI(t *testing.T) { - t.Parallel() - - t.Run("generates token with JTI and revokes correctly", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - // Use validator without audience so generated tokens validate. - validator := NewJWTValidator(key, "") - defer validator.blacklist.Stop() - - token, jti, err := validator.GenerateTokenWithJTI("user-123", []string{"read"}, 1*time.Hour, 2*time.Hour) - require.NoError(t, err) - require.NotEmpty(t, token) - require.NotEmpty(t, jti) - - // GenerateTokenWithJTI adds JTI to blacklist immediately. - // So the token is already revoked — this is by design - // (anyone with the JTI can revoke the token). - _, err = validator.Validate(token) - require.Error(t, err) - require.Contains(t, err.Error(), "revoked") - // IsRevoked confirms - require.True(t, validator.IsRevoked(jti)) - }) - - t.Run("invalid secret type", func(t *testing.T) { - t.Parallel() - validator := &JWTValidator{ - secret: 12345, - audience: "test", - blacklist: newJTIBlacklist(), - } - defer validator.blacklist.Stop() - - token, jti, err := validator.GenerateTokenWithJTI("user-123", []string{"read"}, 1*time.Hour, 2*time.Hour) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid secret type") - require.Empty(t, token) - require.Empty(t, jti) - }) - - t.Run("blacklist TTL defaults to 10min when token TTL is zero", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - validator := NewJWTValidator(key, "") - defer validator.blacklist.Stop() - - // Token with 0 TTL (edge case) - token, jti, err := validator.GenerateTokenWithJTI("user-123", []string{"read"}, 0, 0) - require.NoError(t, err) - require.NotEmpty(t, token) - require.NotEmpty(t, jti) - }) -} - -// ─── RevokeToken / IsRevoked ──────────────────────────────────────────────────── - -func TestRevokeTokenAndIsRevoked(t *testing.T) { - t.Parallel() - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - validator := NewJWTValidator(key, "test-audience") - defer validator.blacklist.Stop() - - jti := mustGenerateJTI() - - // Initially not revoked - require.False(t, validator.IsRevoked(jti)) - - // Revoke the token - validator.RevokeToken(jti, 10*time.Minute) - - // Should be revoked now - require.True(t, validator.IsRevoked(jti)) - - // Empty JTI should return false - require.False(t, validator.IsRevoked("")) - - // Revoking empty JTI should be no-op - validator.RevokeToken("", 10*time.Minute) -} - -// ─── JTI Blacklist ────────────────────────────────────────────────────────────── - -func TestJTIBlacklist(t *testing.T) { - t.Parallel() - - t.Run("newJTIBlacklist initializes properly", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - require.NotNil(t, b) - require.NotNil(t, b.stopCh) - defer b.Stop() - }) - - t.Run("revoke and isRevoked", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - defer b.Stop() - - jti := mustGenerateJTI() - - // Not revoked initially - require.False(t, b.isRevoked(jti)) - - // Revoke for 100ms - b.revoke(jti, 100*time.Millisecond) - require.True(t, b.isRevoked(jti)) - - // Wait for expiration - time.Sleep(150 * time.Millisecond) - require.False(t, b.isRevoked(jti)) - }) - - t.Run("isRevoked with empty jti", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - defer b.Stop() - - require.False(t, b.isRevoked("")) - }) - - t.Run("revoke with empty jti is no-op", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - defer b.Stop() - - b.revoke("", 10*time.Minute) - require.Equal(t, 0, b.Size()) - }) - - t.Run("sweep removes expired entries", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - defer b.Stop() - - // Add multiple entries with short TTL - jti1 := mustGenerateJTI() - jti2 := mustGenerateJTI() - b.revoke(jti1, 50*time.Millisecond) - b.revoke(jti2, 50*time.Millisecond) - - require.Equal(t, 2, b.Size()) - - // Wait for sweep (runs every 1 minute, but we can trigger manually by waiting) - time.Sleep(100 * time.Millisecond) - - // Access to trigger lazy deletion - require.False(t, b.isRevoked(jti1)) - require.False(t, b.isRevoked(jti2)) - }) - - t.Run("Size returns correct count", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - defer b.Stop() - - require.Equal(t, 0, b.Size()) - - b.revoke(mustGenerateJTI(), 10*time.Minute) - require.Equal(t, 1, b.Size()) - - b.revoke(mustGenerateJTI(), 10*time.Minute) - require.Equal(t, 2, b.Size()) - }) - - t.Run("Stop stops the sweeper", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - - // Stop should not panic - require.NotPanics(t, func() { - b.Stop() - }) - }) - - t.Run("double Stop does not panic", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - - require.NotPanics(t, func() { - b.Stop() - b.Stop() - }) - }) - - t.Run("concurrent access", func(t *testing.T) { - t.Parallel() - b := newJTIBlacklist() - defer b.Stop() - - var wg sync.WaitGroup - numOps := 100 - - // Concurrent revokes - for i := 0; i < numOps; i++ { - wg.Add(1) - go func() { - defer wg.Done() - b.revoke(mustGenerateJTI(), 10*time.Minute) - }() - } - - // Concurrent checks - for i := 0; i < numOps; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = b.isRevoked(mustGenerateJTI()) - }() - } - - wg.Wait() - // Should complete without race conditions - }) -} - -// ─── GenerateJTI ──────────────────────────────────────────────────────────────── - -func TestGenerateJTI(t *testing.T) { - t.Parallel() - - t.Run("generates valid UUID v4", func(t *testing.T) { - t.Parallel() - jti, err := GenerateJTI() - require.NoError(t, err) - require.NotEmpty(t, jti) - - // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - require.Len(t, jti, 36) - require.Equal(t, byte('-'), jti[8]) - require.Equal(t, byte('-'), jti[13]) - require.Equal(t, byte('4'), jti[14]) // version 4 - require.Equal(t, byte('-'), jti[18]) - require.Contains(t, "89ab", string(jti[19])) // variant - require.Equal(t, byte('-'), jti[23]) - }) - - t.Run("generates unique JTIs", func(t *testing.T) { - t.Parallel() - jtis := make(map[string]bool) - for i := 0; i < 1000; i++ { - jti, err := GenerateJTI() - require.NoError(t, err) - require.False(t, jtis[jti], "duplicate JTI generated: %s", jti) - jtis[jti] = true - } - }) -} - -// ─── mustGenerateJTI ──────────────────────────────────────────────────────────── - -func TestMustGenerateJTI(t *testing.T) { - t.Parallel() - - t.Run("generates non-empty JTI", func(t *testing.T) { - t.Parallel() - jti := mustGenerateJTI() - require.NotEmpty(t, jti) - require.Len(t, jti, 36) // UUID format - }) - - t.Run("generates unique JTIs", func(t *testing.T) { - t.Parallel() - jtis := make(map[string]bool) - for i := 0; i < 100; i++ { - jti := mustGenerateJTI() - require.False(t, jtis[jti], "duplicate JTI generated: %s", jti) - jtis[jti] = true - } - }) -} - -// ─── Integration Tests ────────────────────────────────────────────────────────── - -func TestJWTIntegration(t *testing.T) { - t.Parallel() - - t.Run("full lifecycle: generate, validate, revoke", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - // Use validator without audience so generated tokens (no audience) validate. - validator := NewJWTValidator(key, "") - defer validator.blacklist.Stop() - - // Generate token - token, err := validator.GenerateToken("user-123", []string{"read", "write"}, 1*time.Hour) - require.NoError(t, err) - - // Validate token - claims, err := validator.Validate(token) - require.NoError(t, err) - require.Equal(t, "user-123", claims.UserID) - - // Revoke by JTI - validator.RevokeToken(claims.ID, 10*time.Minute) - - // Validation should fail - _, err = validator.Validate(token) - require.Error(t, err) - require.Contains(t, err.Error(), "revoked") - }) - - t.Run("token with custom claims", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - validator := NewJWTValidator(key, "") - defer validator.blacklist.Stop() - - // Generate token with custom claims - customClaims := &JWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "hotplex", - Subject: "user-456", - Audience: []string{"hotplex-gateway"}, - ID: mustGenerateJTI(), - }, - UserID: "user-456", - Scopes: []string{"admin", "read", "write", "delete"}, - Role: "admin", - BotID: "bot-789", - SessionID: "sess-xyz123", - } - - token, err := validator.GenerateTokenWithClaims(customClaims) - require.NoError(t, err) - - // Validate and check all fields - claims, err := validator.Validate(token) - require.NoError(t, err) - require.Equal(t, "user-456", claims.UserID) - require.Equal(t, []string{"admin", "read", "write", "delete"}, claims.Scopes) - require.Equal(t, "admin", claims.Role) - require.Equal(t, "bot-789", claims.BotID) - require.Equal(t, "sess-xyz123", claims.SessionID) - }) - - t.Run("concurrent token validation", func(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - validator := NewJWTValidator(key, "") - defer validator.blacklist.Stop() - - // Generate multiple tokens - tokens := make([]string, 10) - for i := 0; i < 10; i++ { - token, err := validator.GenerateToken(fmt.Sprintf("user-%d", i), []string{"read"}, 1*time.Hour) - require.NoError(t, err) - tokens[i] = token - } - - var wg sync.WaitGroup - for i := 0; i < 10; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - claims, err := validator.Validate(tokens[idx]) - require.NoError(t, err) - require.Equal(t, fmt.Sprintf("user-%d", idx), claims.UserID) - }(i) - } - - wg.Wait() - }) -} diff --git a/internal/session/key.go b/internal/session/key.go index b4d0acaf..cc37976d 100644 --- a/internal/session/key.go +++ b/internal/session/key.go @@ -33,7 +33,7 @@ func DeriveSessionKey(ownerID string, wt worker.WorkerType, clientKey, workDir s // ThreadTS is cross-platform: used by both Slack threads and Feishu chat threads. type PlatformContext struct { Platform string - BotID string // Bot identity (Slack UserID, Feishu OpenID, WebChat JWT bot_id) + BotID string // Bot identity (Slack UserID, Feishu OpenID, WebChat X-Bot-ID header) // Slack fields TeamID string ChannelID string diff --git a/internal/worker/base/env.go b/internal/worker/base/env.go index 80c5e7dc..ce6f47df 100644 --- a/internal/worker/base/env.go +++ b/internal/worker/base/env.go @@ -43,7 +43,7 @@ func setOrAppend(env []string, entry string) []string { // // Only vars prefixed with HOTPLEX_WORKER_ are stripped and passed to workers. // Example: HOTPLEX_WORKER_GITHUB_TOKEN=xxx → GITHUB_TOKEN=xxx in worker env. -// All other HOTPLEX_* vars (JWT_SECRET, ADMIN_TOKEN, etc.) are gateway-internal +// All other HOTPLEX_* vars (ADMIN_TOKEN, etc.) are gateway-internal // and blocked from reaching workers. // When a stripped var exists, the system-level version is dynamically blocked // to prevent the gateway's own secrets from leaking to workers. diff --git a/scripts/README.md b/scripts/README.md index 21270eda..e2e01d0d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -22,7 +22,7 @@ This directory contains installation and deployment scripts for HotPlex Worker G - Checks system dependencies (Go 1.21+, OpenSSL) - Builds binary with version injection - Creates directory structure (`/etc/hotplex`, `/var/lib/hotplex`, `/var/log/hotplex`) -- Generates secrets (JWT secret, admin tokens) +- Generates secrets (admin tokens) - Generates TLS certificates (self-signed or Let's Encrypt integration) - Creates configuration file - Installs systemd service (Linux) @@ -56,7 +56,7 @@ sudo ./scripts/install.sh --systemd /usr/local/bin/hotplex # Binary /etc/hotplex/ ├── config.yaml # Main config - ├── secrets.env # Secrets (JWT, tokens) + ├── secrets.env # Secrets (tokens) ├── config.env.example # Environment template └── tls/ ├── server.crt # TLS certificate @@ -149,20 +149,17 @@ curl http://localhost:9999/admin/health ```bash # Development docker run -p 8080:8888 -p 9080:9999 \ - -e HOTPLEX_JWT_SECRET=your-secret \ hotplex:latest # With custom config docker run -p 8080:8888 -p 9080:9999 \ -v /path/to/config.yaml:/etc/hotplex/config.yaml \ - -e HOTPLEX_JWT_SECRET=your-secret \ hotplex:latest # With TLS docker run -p 8443:8443 -p 9080:9999 \ -v /path/to/tls.crt:/etc/hotplex/tls/server.crt \ -v /path/to/tls.key:/etc/hotplex/tls/server.key \ - -e HOTPLEX_JWT_SECRET=your-secret \ hotplex:latest ``` @@ -498,7 +495,6 @@ docker-compose down -v ```bash # Required -export HOTPLEX_JWT_SECRET="your-jwt-secret" export HOTPLEX_ADMIN_TOKEN="your-admin-token" # Optional @@ -528,7 +524,6 @@ docker network create traefik-network **Production checklist:** -- [ ] Set strong `HOTPLEX_JWT_SECRET` - [ ] Set strong `HOTPLEX_ADMIN_TOKEN` - [ ] Configure `GRAFANA_PASSWORD` - [ ] Update Traefik dashboard host (`traefik.hotplex.dev`) @@ -550,7 +545,7 @@ source /etc/hotplex/secrets.env ```bash # Option 1: Vault -export HOTPLEX_JWT_SECRET=$(vault read -field=jwt_secret secret/hotplex) +export HOTPLEX_ADMIN_TOKEN=$(vault read -field=admin_token secret/hotplex) # Option 2: Kubernetes Secrets envFrom: @@ -559,7 +554,7 @@ envFrom: # Option 3: Docker Swarm Secrets secrets: - - hotplex_jwt_secret + - hotplex_admin_token ``` ### TLS Certificates diff --git a/webchat/lib/ai-sdk-transport/client/types.ts b/webchat/lib/ai-sdk-transport/client/types.ts index 86e0f611..d2659e78 100644 --- a/webchat/lib/ai-sdk-transport/client/types.ts +++ b/webchat/lib/ai-sdk-transport/client/types.ts @@ -257,6 +257,7 @@ export interface InitData { export interface InitAuth { token?: string; + bot_id?: string; } export interface InitConfig {