feat: encrypt desktop sub requests

This commit is contained in:
gongzhiyong
2026-05-27 18:15:29 +08:00
parent f466c40545
commit 9f5b4ba777
6 changed files with 369 additions and 52 deletions
@@ -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 联调不可排障。
@@ -22,7 +22,59 @@ Manager 负责辅助控制面:任务草稿桥接、资源/权限、Agnet deplo
| `/api/agnet/user/*` | Manager 用户态 Agnet 控制面,deployment、timeline、artifact、审批 | Manager 登录 session cookie + `New-Api-User: <user_id>` |
| `/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: <device_id>
X-Heicode-Timestamp: <unix_ms>
X-Heicode-Nonce: <nonce_hex>
X-Heicode-Fingerprint: <device_fingerprint>
X-Heicode-Eph-Pubkey: <base64_x25519_ephemeral_pubkey>
X-Heicode-Signature: <base64_ed25519_signature>
X-Heicode-Client-Version: <desktop_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 <heicode_access_token>
```
原因:该 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
成功:
+1 -1
View File
@@ -1 +1 @@
1.4.6
1.4.7
+57 -39
View File
@@ -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()
+245
View File
@@ -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
}
+2 -2
View File
@@ -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)