feat(security): LLM api_key 端到端加密(AES-256-GCM,磁盘+线缆均密文)

新增 sundynix-shared/secrets:AES-256-GCM,密钥由 SUNDYNIX_SECRET_KEY 经
SHA-256 派生;密文带 enc:1: 版本前缀,历史明文行自动透传(下次保存升级为密文)。

- 网关 SaveModel 加密落库;ListModels/TestModel 解密后脱敏/探测;
  空或脱敏占位的 api_key 视为「未改」→ 沿用库内既有密文(不二次加密)。
- 密文经 NATS 原样下发;消费方解密集中在 bus 层 decryptConfig
  (RequestConfig + SubscribeConfigUpdated)→ dispatcher/mcp-go 零改动。
- secrets.MustHaveKeyInProd():生产未设 SUNDYNIX_SECRET_KEY 直接 fatal,
  gateway/dispatcher/mcp-go 启动各调一次(三服务须配相同密钥)。
- 修复 store.SaveModel 整行 Save 把 active 清零的旧 bug:改 Select(...).Updates
  只覆盖可编辑列,改 key 不再顺手取消模型激活。
- secrets 单测:往返/空串/随机 nonce/错密钥 fail-closed/历史明文透传。
- production_readiness.md 2.1 更新为「已落地」。

验证:DB 列由明文 sk-…(35) → 密文 enc:1:…(90);dispatcher 从密文广播解密后
model config set;真实任务 √256→16、12×12→144 打通 DeepSeek(非降级桩)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-24 10:36:34 +08:00
parent 5cfed55e91
commit 46ef3df221
9 changed files with 274 additions and 20 deletions
+41 -11
View File
@@ -5,14 +5,33 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-shared/secrets"
)
// maskPrefix 是脱敏展示用的占位前缀;前端把列表里的脱敏 key 原样回传即视为「未改动」。
const maskPrefix = "••••"
// existingModelKey 取某 id 模型库内存储的 api_key(密文,未解密);不存在返回空。
func (h *Handler) existingModelKey(ctx context.Context, id string) string {
if id == "" {
return ""
}
rows, _ := h.db.ListModels(ctx, "")
for _, m := range rows {
if m.ID == id {
return m.APIKey
}
}
return ""
}
// 控制面(运维管理):LLM 模型配置 CRUD + 测试连接 + 变更广播。
// 表 sundynix_model 由 Gateway 持有;Dispatcher 经 NATS 取激活配置。
@@ -34,9 +53,10 @@ func (h *Handler) ListModels(c *gin.Context) {
}
out := make([]gin.H, 0, len(rows))
for _, m := range rows {
plain, _ := secrets.Decrypt(m.APIKey) // 库内为密文,脱敏前先还原以展示真实尾 4 位
out = append(out, gin.H{
"id": m.ID, "kind": m.Kind, "provider": m.Provider, "base_url": m.BaseURL,
"model": m.Model, "active": m.Active, "api_key": mask(m.APIKey),
"model": m.Model, "active": m.Active, "api_key": mask(plain),
})
}
c.JSON(http.StatusOK, gin.H{"models": out})
@@ -99,7 +119,19 @@ func (h *Handler) SaveModel(c *gin.Context) {
if kind == "" {
kind = contract.ConfigKindChat
}
m := &store.LLMModel{BaseModel: store.BaseModel{ID: b.ID}, Kind: kind, Provider: provider, BaseURL: b.BaseURL, APIKey: b.APIKey, Model: b.Model}
// api_key 处理:空或脱敏占位 => 沿用库内既有密文(更新时未改 key);否则视为新明文,加密落库。
apiKey := b.APIKey
if apiKey == "" || strings.HasPrefix(apiKey, maskPrefix) {
apiKey = h.existingModelKey(c.Request.Context(), b.ID) // 已是密文,原样保留(不存在则空)
} else {
enc, err := secrets.Encrypt(apiKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt api_key: " + err.Error()})
return
}
apiKey = enc
}
m := &store.LLMModel{BaseModel: store.BaseModel{ID: b.ID}, Kind: kind, Provider: provider, BaseURL: b.BaseURL, APIKey: apiKey, Model: b.Model}
if err := h.db.SaveModel(c.Request.Context(), m); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
@@ -137,16 +169,14 @@ func (h *Handler) TestModel(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required"})
return
}
// 若传了已存的 id 但未带 key,用库里的真实 key。
// 若未带 key(或回传脱敏占位),用库里的真实 key。
key := b.APIKey
if key == "" && b.ID != "" {
if rows, _ := h.db.ListModels(c.Request.Context(), ""); rows != nil {
for _, m := range rows {
if m.ID == b.ID {
key = m.APIKey
}
}
}
if key == "" || strings.HasPrefix(key, maskPrefix) {
key = h.existingModelKey(c.Request.Context(), b.ID)
}
// key 此刻可能是库内密文,也可能是用户新填的明文;Decrypt 对无前缀明文透传,两种都还原成可用明文。
if plain, err := secrets.Decrypt(key); err == nil {
key = plain
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()