feat: encrypt desktop sub requests
This commit is contained in:
@@ -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
@@ -1 +1 @@
|
||||
1.4.6
|
||||
1.4.7
|
||||
|
||||
+57
-39
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user