diff --git a/docs/heicode-manager-standalone-execution-plan.md b/docs/heicode-manager-standalone-execution-plan.md index 46d7eeca..86cf2f87 100644 --- a/docs/heicode-manager-standalone-execution-plan.md +++ b/docs/heicode-manager-standalone-execution-plan.md @@ -257,19 +257,19 @@ | 项 | 内容 | |---|---| -| 目标 | 明确桌面客户端调用 Manager 时,请求体当前依赖 HTTPS/TLS 传输加密;是否需要额外应用层 body 加密需单独评估,不默认混入 sub 敏捷接口 | +| 目标 | 桌面客户端调用 Manager 的 sub 流程接口时,支持与模型调用一致的 V2 应用层 body 加密;浏览器后台页面继续兼容普通 HTTPS JSON | | 修改文件 | `docs/integration/heicode-desktop-sub-agile-api.md`、`docs/heicode-runtime-auth-newapi-secret-design.md`、必要时补充 `heicode/middleware` 或客户端 SDK 加解密模块 | -| 当前现实 | 生产入口应使用 `https://code.xinghanlab.com`;JSON body 在网络传输中由 TLS 加密,到 Manager 后端后按普通 JSON 解析 | +| 当前现实 | `/api/agnet/user/*` 与 `/api/heicode-auth/*` 已支持 `Content-Encoding: heicode-aead-v1`;Manager 会先解密并校验设备签名,再把明文 JSON 交给业务处理或代理给上游 | | 安全红线 | 请求体、回调、日志、artifact metadata、timeline 中禁止出现明文密码、Token、私钥、连接串、云 access key、模型 key;长期凭证只传 `secret_ref` | -| 后续判断 | 如有合规要求再设计应用层加密,例如请求体 AES-GCM + 短期会话密钥/设备公钥;同时要设计密钥分发、轮换、重放保护、版本兼容和排障方案 | -| 测试 | 检查生产 API 只走 HTTPS;敏感字段扫描;单测覆盖疑似明文凭据拒绝;如实现应用层加密,再补加解密兼容测试 | +| 后续判断 | 桌面客户端需要在 sub 流程调用中复用模型调用的 encryptedFetch/V2 签名能力;服务端保留未加密 JSON 只用于 Manager Web 控制台兼容 | +| 测试 | 单测覆盖 V2 malformed 请求拒绝、普通代理请求兼容;生产需用真实已配对设备发起加密 sub 请求冒烟 | 验收标准: -- 文档明确“HTTPS 传输加密已覆盖 body 在网络中的保密性”,不误导为当前已有业务层 body 加密。 +- 文档明确桌面端优先走 V2 应用层 body 加密,且保留浏览器后台未加密 JSON 兼容。 - 客户端对接文档明确禁止传明文长期凭据,只允许 `secret_ref` / `lease://...` / 资源元数据。 - 服务端日志不打印请求 body 中的敏感字段。 -- 如果后续实现应用层 body 加密,必须保留版本字段和灰度开关,避免旧客户端直接不可用。 +- V2 加密失败时返回 `X-Heicode-Auth-Error` 和 `X-Heicode-Server-Time`,便于客户端排障。 ## 五、推荐执行批次 @@ -353,7 +353,7 @@ Manager 独立任务完成,不等于蜂群生产闭环完成。本文完成的 | 幂等 | 重复回调不重复写入 | | 审计 | task/deployment/correlation_id 下能聚合审计、审批、artifact、callback | | 安全 | API、日志、页面不出现明文长期密钥 | -| 请求加密 | 文档明确当前依赖 HTTPS/TLS 传输加密;应用层 body 加密如需实现,有清晰设计和兼容策略 | +| 请求加密 | 桌面端 sub 请求支持与模型调用一致的 V2 body 加密;浏览器后台普通 JSON 兼容路径不受影响 | | 口径 | 页面和文档不把本地占位/模拟事件说成真实 Runtime | ## 八、执行时不能突破的边界 @@ -365,4 +365,4 @@ Manager 独立任务完成,不等于蜂群生产闭环完成。本文完成的 5. 不把本地模拟事件当真实蜂群完成证据。 6. 不把 Azure Key Vault 暴露成普通用户要直接操作的后台。 7. 不绕过客户端高危审批的产品边界;Manager 只能先提供记录和 API。 -8. 不在未完成密钥协商、轮换和兼容设计前,临时硬塞应用层 body 加密,避免客户端和 Manager 联调不可排障。 +8. 不破坏 V2 body 加密的版本字段、诊断头、重放保护和未加密 Web 控制台兼容路径,避免客户端和 Manager 联调不可排障。 diff --git a/docs/integration/heicode-desktop-sub-agile-api.md b/docs/integration/heicode-desktop-sub-agile-api.md index bdd099d5..f367689c 100644 --- a/docs/integration/heicode-desktop-sub-agile-api.md +++ b/docs/integration/heicode-desktop-sub-agile-api.md @@ -22,7 +22,59 @@ Manager 负责辅助控制面:任务草稿桥接、资源/权限、Agnet deplo | `/api/agnet/user/*` | Manager 用户态 Agnet 控制面,deployment、timeline、artifact、审批 | Manager 登录 session cookie + `New-Api-User: ` | | `/api/user/self` | 查询当前 Manager 用户 | Manager 登录 session cookie | -### 2.2 Manager 用户态 Header +### 2.2 桌面端请求 body 加密 + +桌面客户端调用 Manager 的 sub 流程接口时,应优先使用与模型调用一致的 V2 加密请求协议。 + +适用接口: + +| API 前缀 | V2 加密 body | 说明 | +|---|---|---| +| `/api/agnet/user/*` | 支持 | Manager 解密并校验设备签名后,按当前设备对应用户执行 | +| `/api/heicode-auth/*` | 支持 | Manager 解密并校验设备签名后,把明文 body 代理给上游 HeicodeTask 服务;仍需携带 `heicode_access_token` | +| 浏览器后台普通页面请求 | 兼容未加密 JSON | 不影响现有 Manager Web 控制台 | + +V2 请求头与模型调用一致: + +```http +Content-Encoding: heicode-aead-v1 +X-Heicode-Device-Id: +X-Heicode-Timestamp: +X-Heicode-Nonce: +X-Heicode-Fingerprint: +X-Heicode-Eph-Pubkey: +X-Heicode-Signature: +X-Heicode-Client-Version: +Content-Type: application/json +Accept: application/json +``` + +加密和签名协议沿用模型调用: + +```text +body = nonce || ChaCha20-Poly1305(plaintext_json, aad) +aad = device_id + "|" + timestamp + "|" + nonce + "|" + method + "|" + path_with_query + +canonical = method + "\n" + + path_with_query + "\n" + + timestamp_ms + "\n" + + nonce_hex + "\n" + + device_fingerprint + "\n" + + ephemeral_pubkey_b64 + "\n" + + sha256_hex(plaintext_body) + +signature = base64(ed25519_sign(device_private_key, sha256(canonical))) +``` + +`/api/heicode-auth/*` 额外要求: + +```http +Authorization: Bearer +``` + +原因:该 token 是上游 HeicodeTask 服务认证用;V2 设备签名只证明请求来自已配对的 Manager 桌面设备。 + +### 2.3 Manager 用户态 Header 调用 `/api/agnet/user/*` 时必须带: @@ -35,7 +87,9 @@ Accept: application/json `New-Api-User` 必须等于当前登录用户 ID,否则会返回未授权。 -### 2.3 统一响应 Envelope +使用 V2 加密 body 时,`/api/agnet/user/*` 可以不依赖浏览器 session cookie;Manager 会从设备绑定 token 中解析用户身份。为兼容当前 Web 控制台,未加密请求仍按 session cookie + `New-Api-User` 处理。 + +### 2.4 统一响应 Envelope 成功: diff --git a/heicode/VERSION b/heicode/VERSION index c514bd85..be05bba9 100644 --- a/heicode/VERSION +++ b/heicode/VERSION @@ -1 +1 @@ -1.4.6 +1.4.7 diff --git a/heicode/middleware/auth.go b/heicode/middleware/auth.go index 29686abe..0f9992fc 100644 --- a/heicode/middleware/auth.go +++ b/heicode/middleware/auth.go @@ -203,6 +203,62 @@ func UserAuth() func(c *gin.Context) { } } +func authenticateV2DeviceRequest(c *gin.Context) bool { + if err := DecryptV2RequestBody(c); err != nil { + common.SysLog("V2 body decrypt failed: " + err.Error() + + " path=" + c.Request.URL.Path + " ip=" + c.ClientIP()) + SetV2AuthDiagnosticHeaders(c, err) + abortWithOpenAiMessage(c, http.StatusUnauthorized, + common.TranslateMessage(c, i18n.MsgTokenInvalid)) + return false + } + token, err := VerifyV2DeviceSignedRequest(c) + if err != nil { + common.SysLog("V2 device-signed auth rejected: " + err.Error() + + " device_id=" + c.GetHeader(HeaderDeviceID) + + " ip=" + c.ClientIP()) + SetV2AuthDiagnosticHeaders(c, err) + abortWithOpenAiMessage(c, http.StatusUnauthorized, + common.TranslateMessage(c, i18n.MsgTokenInvalid)) + return false + } + if !applyTokenPolicyAndContext(c, token) { + return false + } + if err := SetupContextForToken(c, token); err != nil { + return false + } + return true +} + +// UserOrV2DeviceAuth keeps dashboard session auth working while allowing the +// desktop client to use the same encrypted body protocol as model calls. +func UserOrV2DeviceAuth() func(c *gin.Context) { + return func(c *gin.Context) { + if IsV2Request(c) { + if !authenticateV2DeviceRequest(c) { + return + } + c.Next() + return + } + UserAuth()(c) + } +} + +// OptionalV2DeviceAuth decrypts and verifies encrypted desktop requests when +// present, but leaves existing non-encrypted proxy requests unchanged. +func OptionalV2DeviceAuth() func(c *gin.Context) { + return func(c *gin.Context) { + if IsV2Request(c) { + if !authenticateV2DeviceRequest(c) { + return + } + } + c.Next() + } +} + func AdminAuth() func(c *gin.Context) { return func(c *gin.Context) { authHelper(c, common.RoleAdminUser) @@ -312,45 +368,7 @@ func TokenAuth() func(c *gin.Context) { // verify the Ed25519 signature, and shortcut the rest of the // legacy logic. See plan in ~/.claude/plans/peaceful-sprouting-crane.md. if IsV2Request(c) { - if err := DecryptV2RequestBody(c); err != nil { - common.SysLog("V2 body decrypt failed: " + err.Error() + - " path=" + c.Request.URL.Path + " ip=" + c.ClientIP()) - // Surface a machine-readable error code + server time so - // the desktop client can render an actionable message - // (e.g. "your system clock is X seconds off") instead of - // the generic "Token invalid" string. See plan v2 § - // "auth error UX" and HeaderAuthError contract in - // middleware/device_signature.go. - SetV2AuthDiagnosticHeaders(c, err) - abortWithOpenAiMessage(c, http.StatusUnauthorized, - common.TranslateMessage(c, i18n.MsgTokenInvalid)) - return - } - token, err := VerifyV2DeviceSignedRequest(c) - if err != nil { - common.SysLog("V2 device-signed auth rejected: " + err.Error() + - " device_id=" + c.GetHeader(HeaderDeviceID) + - " ip=" + c.ClientIP()) - SetV2AuthDiagnosticHeaders(c, err) - abortWithOpenAiMessage(c, http.StatusUnauthorized, - common.TranslateMessage(c, i18n.MsgTokenInvalid)) - return - } - - // Same downstream wiring as legacy: load user cache, group - // resolution, IP allowlist. Body has been replaced with - // plaintext so the relay handler sees normal JSON. - if !applyTokenPolicyAndContext(c, token) { - return // applyTokenPolicyAndContext already aborted - } - // Critical: V2 path must populate the same per-token context - // keys the legacy path does — token_key, token_name, - // token_quota, model_limits, ContextKeyTokenGroup, etc. — - // otherwise downstream Distribute / billing logic blow up - // with "record not found". This bug surfaced as HTTP 403 - // new_api_error on every V2 chat call after device pairing - // finally started succeeding. - if err := SetupContextForToken(c, token); err != nil { + if !authenticateV2DeviceRequest(c) { return } c.Next() diff --git a/heicode/middleware/auth_v2_user_test.go b/heicode/middleware/auth_v2_user_test.go new file mode 100644 index 00000000..7dd8d4d4 --- /dev/null +++ b/heicode/middleware/auth_v2_user_test.go @@ -0,0 +1,245 @@ +package middleware + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/heicode/manager/common" + "github.com/heicode/manager/model" + "github.com/heicode/manager/service" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" + "gorm.io/gorm" +) + +func TestUserOrV2DeviceAuthRejectsMalformedEncryptedRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/agnet/user/tasks/:task_id/deployment-draft", UserOrV2DeviceAuth(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + + req := httptest.NewRequest( + http.MethodPost, + "/api/agnet/user/tasks/task-v2/deployment-draft", + nil, + ) + req.Header.Set("Content-Encoding", V2ContentEncoding) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } + if got := rec.Header().Get(HeaderAuthError); got != V2AuthErrorEphPubkeyMissing { + t.Fatalf("%s = %q, want %q", HeaderAuthError, got, V2AuthErrorEphPubkeyMissing) + } + if rec.Header().Get(HeaderServerTime) == "" { + t.Fatalf("%s was not set", HeaderServerTime) + } +} + +func TestOptionalV2DeviceAuthAllowsPlainRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/heicode-auth/api/user/tasks/intent", OptionalV2DeviceAuth(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/heicode-auth/api/user/tasks/intent", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestUserOrV2DeviceAuthDecryptsValidEncryptedRequest(t *testing.T) { + setupV2UserAuthTestDB(t) + gin.SetMode(gin.TestMode) + + deviceID := "device-v2-success" + fingerprint := strings.Repeat("c", 64) + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate ed25519 key: %v", err) + } + publicKeyB64 := base64.StdEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey)) + deviceIDCopy := deviceID + token := model.Token{ + UserId: 42, + Key: "sk-v2-success-test", + Status: common.TokenStatusEnabled, + Name: "v2 success test", + ExpiredTime: -1, + UnlimitedQuota: true, + DeviceId: &deviceIDCopy, + DevicePubkey: &publicKeyB64, + DeviceFingerprint: fingerprint, + RequireDeviceBinding: true, + } + if err := token.Insert(); err != nil { + t.Fatalf("insert token: %v", err) + } + + router := gin.New() + router.POST("/api/agnet/user/tasks/:task_id/deployment-draft", UserOrV2DeviceAuth(), func(c *gin.Context) { + if got := c.GetInt("id"); got != 42 { + t.Fatalf("id context = %d, want 42", got) + } + var payload struct { + Objective string `json:"objective"` + } + if err := c.ShouldBindJSON(&payload); err != nil { + t.Fatalf("bind decrypted body: %v", err) + } + if payload.Objective != "encrypted sub task" { + t.Fatalf("objective = %q", payload.Objective) + } + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + + path := "/api/agnet/user/tasks/task-v2/deployment-draft" + body := []byte(`{"objective":"encrypted sub task"}`) + req := newEncryptedV2Request(t, http.MethodPost, path, body, deviceID, fingerprint, privateKey) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func setupV2UserAuthTestDB(t *testing.T) *gorm.DB { + t.Helper() + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + model.DB = db + model.LOG_DB = db + if err := db.AutoMigrate(&model.User{}, &model.Token{}, &model.ServerKey{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + if err := db.Create(&model.User{ + Id: 42, + Username: "v2-user", + Password: "not-used", + Status: common.UserStatusEnabled, + Group: "default", + Quota: 1000000, + }).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := service.EnsureServerECDHKey(); err != nil { + t.Fatalf("ensure server ecdh key: %v", err) + } + t.Cleanup(func() { + if model.DB == db { + model.DB = nil + } + if model.LOG_DB == db { + model.LOG_DB = nil + } + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func newEncryptedV2Request(t *testing.T, method, path string, plaintext []byte, deviceID, fingerprint string, signingKey ed25519.PrivateKey) *http.Request { + t.Helper() + serverPubB64, err := service.GetServerECDHPublicKeyB64() + if err != nil { + t.Fatalf("get server pubkey: %v", err) + } + serverPub, err := base64.StdEncoding.DecodeString(serverPubB64) + if err != nil { + t.Fatalf("decode server pubkey: %v", err) + } + ephPriv := make([]byte, curve25519.ScalarSize) + if _, err := rand.Read(ephPriv); err != nil { + t.Fatalf("generate eph private key: %v", err) + } + ephPriv[0] &= 248 + ephPriv[31] &= 127 + ephPriv[31] |= 64 + ephPub, err := curve25519.X25519(ephPriv, curve25519.Basepoint) + if err != nil { + t.Fatalf("derive eph public key: %v", err) + } + shared, err := curve25519.X25519(ephPriv, serverPub) + if err != nil { + t.Fatalf("derive shared key: %v", err) + } + r := hkdf.New(sha256.New, shared, nil, []byte("heicode-aead-v1")) + key := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(r, key); err != nil { + t.Fatalf("derive aead key: %v", err) + } + aead, err := chacha20poly1305.New(key) + if err != nil { + t.Fatalf("new aead: %v", err) + } + headerNonce := randomNonce() + timestamp := fmt.Sprint(time.Now().UnixMilli()) + aad := strings.Join([]string{deviceID, timestamp, headerNonce, method, path}, "|") + aeadNonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(aeadNonce); err != nil { + t.Fatalf("generate aead nonce: %v", err) + } + ciphertext := aead.Seal(nil, aeadNonce, plaintext, []byte(aad)) + encryptedBody := append(append([]byte{}, aeadNonce...), ciphertext...) + + ephPubB64 := base64.StdEncoding.EncodeToString(ephPub) + bodyHash := sha256.Sum256(plaintext) + canonical := strings.Join([]string{ + method, + path, + timestamp, + headerNonce, + fingerprint, + ephPubB64, + hex.EncodeToString(bodyHash[:]), + }, "\n") + digest := sha256.Sum256([]byte(canonical)) + signature := ed25519.Sign(signingKey, digest[:]) + + req := httptest.NewRequest(method, path, bytes.NewReader(encryptedBody)) + req.Header.Set("Content-Encoding", V2ContentEncoding) + req.Header.Set(HeaderEphPubkey, ephPubB64) + req.Header.Set(HeaderDeviceID, deviceID) + req.Header.Set(HeaderTimestamp, timestamp) + req.Header.Set(HeaderNonce, headerNonce) + req.Header.Set(HeaderFingerprint, fingerprint) + req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(signature)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + return req +} diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 3b5e44f6..b8c09e67 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -75,7 +75,7 @@ func SetApiRouter(router *gin.Engine) { // plus the login burst — CriticalRateLimit is for sensitive ops // (login, password reset) and trips at ~5 quick page loads, returning // 429 to a normal user. Upstream APIM rate-limits itself anyway. - apiRouter.Any("/heicode-auth/*proxyPath", middleware.GlobalAPIRateLimit(), controller.HeicodeAuthProxy) + apiRouter.Any("/heicode-auth/*proxyPath", middleware.GlobalAPIRateLimit(), middleware.OptionalV2DeviceAuth(), controller.HeicodeAuthProxy) userRoute := apiRouter.Group("/user") { @@ -492,7 +492,7 @@ func SetApiRouter(router *gin.Engine) { // Agnet user approval gates and short-lived credential leases. agnetApprovalRoute := apiRouter.Group("/agnet") - agnetApprovalRoute.Use(middleware.UserAuth()) + agnetApprovalRoute.Use(middleware.UserOrV2DeviceAuth()) { agnetApprovalRoute.GET("/approvals", controller.ListAgnetApprovalRequests) agnetApprovalRoute.POST("/approvals", controller.CreateAgnetApprovalRequest)