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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user