From 46ef3df2210c31c43ae4036655f141e5aebd21e5 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 24 Jun 2026 10:36:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20LLM=20api=5Fkey=20=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E5=8A=A0=E5=AF=86=EF=BC=88AES-256-GCM?= =?UTF-8?q?=EF=BC=8C=E7=A3=81=E7=9B=98+=E7=BA=BF=E7=BC=86=E5=9D=87?= =?UTF-8?q?=E5=AF=86=E6=96=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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) --- production_readiness.md | 18 ++-- sundynix-dispatcher/cmd/dispatcher/main.go | 2 + sundynix-gateway/cmd/server/main.go | 2 + sundynix-gateway/internal/handler/admin.go | 52 +++++++-- sundynix-gateway/internal/store/model.go | 5 +- sundynix-mcp-go/cmd/server/main.go | 5 +- sundynix-shared/bus/bus.go | 14 +++ sundynix-shared/secrets/secrets.go | 117 +++++++++++++++++++++ sundynix-shared/secrets/secrets_test.go | 79 ++++++++++++++ 9 files changed, 274 insertions(+), 20 deletions(-) create mode 100644 sundynix-shared/secrets/secrets.go create mode 100644 sundynix-shared/secrets/secrets_test.go diff --git a/production_readiness.md b/production_readiness.md index 085a690..ea6e72b 100644 --- a/production_readiness.md +++ b/production_readiness.md @@ -108,18 +108,22 @@ Grafana Dashboard 体系: ## 二、安全加固 (Security Hardening) -### 2.1 密钥管理 — 🔴 高优先 +### 2.1 密钥管理 — ⚠️ 部分(API Key 加密已落地) -**现状**: -- `ModelConfig.APIKey` 明文存 PostgreSQL、经 NATS 明文广播到各消费方 -- JWT 签名密钥硬编码或环境变量 -- 配置文件中明文 DSN(含密码) +**已实现**: +- ✅ **LLM `api_key` 加密存储 + 端到端密文**:`sundynix-shared/secrets`(AES-256-GCM,密钥由 `SUNDYNIX_SECRET_KEY` 经 SHA-256 派生)。 + 网关保存时加密落库(`enc:1:` 前缀),密文经 NATS 原样下发,消费方(dispatcher/mcp-go)在 bus 层解密—— + **磁盘与线缆上均无明文**,仅在构建 LLM 客户端时于内存短暂还原。历史明文行自动透传,下次保存即升级为密文。 + 生产模式(`APP_ENV=production` / `GIN_MODE=release`)下未设 `SUNDYNIX_SECRET_KEY` 直接 fatal(`secrets.MustHaveKeyInProd`); + **各服务必须配置相同的密钥**。 +- ✅ JWT 签名密钥:生产强制 `JWT_SECRET`(未设即 fatal)。 -**需引入**: +**仍待引入**: +- 配置文件中明文 DSN(含密码)→ 见下表 | 方案 | 说明 | 推荐 | |---|---|---| -| **Vault / SOPS** | API Key 加密存储,运行时经 Vault API 获取 | HashiCorp Vault(生产首选)或 Mozilla SOPS(轻量) | +| **Vault / SOPS** | 进一步把 `SUNDYNIX_SECRET_KEY` 等根密钥托管、支持轮换 | HashiCorp Vault(生产首选)或 Mozilla SOPS(轻量) | | **K8s Secrets + CSI** | 集群内密钥挂载 | Secrets Store CSI Driver + Vault Provider | | **NATS TLS** | NATS 客户端↔服务端全链路 TLS,消息传输加密 | 配置 nats-server TLS + 客户端证书 | | **PG SSL** | 数据库连接启用 `sslmode=require` | 当前 DSN 为 `sslmode=disable` | diff --git a/sundynix-dispatcher/cmd/dispatcher/main.go b/sundynix-dispatcher/cmd/dispatcher/main.go index 31bef21..f2b46d5 100644 --- a/sundynix-dispatcher/cmd/dispatcher/main.go +++ b/sundynix-dispatcher/cmd/dispatcher/main.go @@ -14,9 +14,11 @@ import ( "github.com/sundynix/sundynix-dispatcher/internal/harness" "github.com/sundynix/sundynix-dispatcher/internal/llm" dnats "github.com/sundynix/sundynix-dispatcher/internal/nats" + "github.com/sundynix/sundynix-shared/secrets" ) func main() { + secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文 natsURL := envOr("NATS_URL", "nats://localhost:4222") pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群 diff --git a/sundynix-gateway/cmd/server/main.go b/sundynix-gateway/cmd/server/main.go index 53c52bd..9a1cb46 100644 --- a/sundynix-gateway/cmd/server/main.go +++ b/sundynix-gateway/cmd/server/main.go @@ -11,9 +11,11 @@ import ( "github.com/sundynix/sundynix-gateway/internal/router" "github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-shared/contract" + "github.com/sundynix/sundynix-shared/secrets" ) func main() { + secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以加密落库的 api_key natsURL := envOr("NATS_URL", "nats://localhost:4222") pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable") redisAddr := envOr("REDIS_ADDR", "localhost:6379") diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index c21b313..1eb3647 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -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() diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go index 472d0a8..291c118 100644 --- a/sundynix-gateway/internal/store/model.go +++ b/sundynix-gateway/internal/store/model.go @@ -285,7 +285,10 @@ func (p *Postgres) SaveModel(ctx context.Context, m *LLMModel) error { if m.ID == "" { return p.db.WithContext(ctx).Create(m).Error } - return p.db.WithContext(ctx).Save(m).Error + // 更新仅覆盖可编辑列:Active 由 SetActiveModel 单独管理,CreatedAt 不动—— + // 避免整行 Save 把 active 清零(改 key/base_url 时不应顺手取消激活)。 + return p.db.WithContext(ctx).Model(&LLMModel{}).Where("id = ?", m.ID). + Select("Kind", "Provider", "BaseURL", "APIKey", "Model").Updates(m).Error } // SetActiveModel 把指定模型设为激活(同 kind 内其余取消),事务保证每 kind 唯一激活。 diff --git a/sundynix-mcp-go/cmd/server/main.go b/sundynix-mcp-go/cmd/server/main.go index f70924a..720c7a8 100644 --- a/sundynix-mcp-go/cmd/server/main.go +++ b/sundynix-mcp-go/cmd/server/main.go @@ -16,9 +16,12 @@ import ( "github.com/sundynix/sundynix-mcp-go/internal/memory" "github.com/sundynix/sundynix-mcp-go/internal/rag" "github.com/sundynix/sundynix-mcp-go/internal/search" + + "github.com/sundynix/sundynix-shared/secrets" ) func main() { + secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文 natsURL := envOr("NATS_URL", "nats://localhost:4222") pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable") redisAddr := envOr("REDIS_ADDR", "localhost:6379") @@ -41,7 +44,7 @@ func main() { log.Printf("[mcp_go] connected %s", natsURL) engine := search.NewHybrid() // LLM Wiki 混合检索:Bleve + Milvus + Neo4j - mem := memory.Open(pgDSN) // 偏好记忆:sundynix_user_profile(连不上则降级) + mem := memory.Open(pgDSN) // 偏好记忆:sundynix_user_profile(连不上则降级) defer mem.Close() hist := history.Open(redisAddr) // 会话短期历史:Redis(连不上则降级) defer hist.Close() diff --git a/sundynix-shared/bus/bus.go b/sundynix-shared/bus/bus.go index c5f8ad0..5e861cd 100644 --- a/sundynix-shared/bus/bus.go +++ b/sundynix-shared/bus/bus.go @@ -12,8 +12,20 @@ import ( "github.com/nats-io/nats.go/jetstream" "github.com/sundynix/sundynix-shared/contract" + "github.com/sundynix/sundynix-shared/secrets" ) +// decryptConfig 在消费侧把配置里的 api_key 从密文还原为明文(控制面以密文过线缆,见 secrets 包)。 +// 失败(密钥不匹配 / 密文损坏)时清空 api_key 并不再降级阻断——调用方据 Ready() 判定。 +func decryptConfig(cfg *contract.ModelConfig) { + if cfg == nil || cfg.APIKey == "" { + return + } + if plain, err := secrets.Decrypt(cfg.APIKey); err == nil { + cfg.APIKey = plain + } +} + // Bus 持有 NATS 连接与 JetStream 上下文。 type Bus struct { nc *nats.Conn @@ -281,6 +293,7 @@ func (b *Bus) RequestConfig(ctx context.Context, kind string) (*contract.ModelCo if !cfg.Ready() { return nil, nil } + decryptConfig(&cfg) // 线缆上是密文,消费侧还原 return &cfg, nil } @@ -333,6 +346,7 @@ func (b *Bus) SubscribeConfigUpdated(kind string, onUpdate func(*contract.ModelC sub, err := b.nc.Subscribe(contract.ConfigUpdatedSubject(kind), func(m *nats.Msg) { var cfg contract.ModelConfig if json.Unmarshal(m.Data, &cfg) == nil { + decryptConfig(&cfg) // 线缆上是密文,消费侧还原 onUpdate(&cfg) } }) diff --git a/sundynix-shared/secrets/secrets.go b/sundynix-shared/secrets/secrets.go new file mode 100644 index 0000000..6a0fc53 --- /dev/null +++ b/sundynix-shared/secrets/secrets.go @@ -0,0 +1,117 @@ +// Package secrets 提供对称加密,用于把敏感配置(首要是 LLM api_key)加密后落库 / 过总线。 +// +// 设计: +// - AES-256-GCM。密钥由环境变量 SUNDYNIX_SECRET_KEY 经 SHA-256 派生为 32 字节(AEAD 同时保证机密性与完整性)。 +// - 密文格式 "enc:1:" + base64url(nonce || ciphertext),自带版本前缀便于日后轮换算法。 +// - 向后兼容:Decrypt 遇到无前缀的值(历史明文行 / 新填的明文)原样返回,不报错—— +// 于是 PG 里的旧明文 key 仍可用,下一次保存即升级为密文。 +// +// 全链路约定:网关保存时 Encrypt 落库;密文经 DB 读出后原样过 NATS;消费方(dispatcher/mcp-go) +// 在 bus 层 Decrypt 还原。因此 api_key 在「磁盘」与「线缆」上都不再是明文,仅在真正构建 LLM +// 客户端的内存中短暂还原。各服务必须配置相同的 SUNDYNIX_SECRET_KEY。 +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "log" + "os" + "strings" + "sync" +) + +// cipherPrefix 标记一个值是本包产出的密文(含版本号,便于日后算法轮换)。 +const cipherPrefix = "enc:1:" + +// devDefaultKey 是未设置 SUNDYNIX_SECRET_KEY 时的开发兜底(与 JWT 开发默认对称,便于本地各服务互通)。 +// 生产环境必须显式设置 SUNDYNIX_SECRET_KEY,否则加密形同虚设。 +const devDefaultKey = "sundynix-dev-secret-change-me" + +var ( + gcmOnce sync.Once + gcm cipher.AEAD + gcmErr error +) + +// aead 惰性构建并缓存 AES-256-GCM 实例(密钥来自环境变量,进程内固定)。 +func aead() (cipher.AEAD, error) { + gcmOnce.Do(func() { + raw := os.Getenv("SUNDYNIX_SECRET_KEY") + if raw == "" { + raw = devDefaultKey + log.Printf("[secrets] SUNDYNIX_SECRET_KEY 未设置,使用开发默认密钥(生产环境务必设置!)") + } + sum := sha256.Sum256([]byte(raw)) // 任意长度口令 → 固定 32 字节 AES-256 密钥 + block, err := aes.NewCipher(sum[:]) + if err != nil { + gcmErr = err + return + } + gcm, gcmErr = cipher.NewGCM(block) + }) + return gcm, gcmErr +} + +// Encrypt 加密明文,返回带前缀的密文;空串原样返回(无需加密)。 +func Encrypt(plain string) (string, error) { + if plain == "" { + return "", nil + } + a, err := aead() + if err != nil { + return "", err + } + nonce := make([]byte, a.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ct := a.Seal(nonce, nonce, []byte(plain), nil) // 输出 = nonce || 密文+tag + return cipherPrefix + base64.RawURLEncoding.EncodeToString(ct), nil +} + +// Decrypt 还原密文。无 "enc:" 前缀的值视为历史明文,原样返回(平滑迁移)。 +func Decrypt(stored string) (string, error) { + if !IsEncrypted(stored) { + return stored, nil // 历史明文 / 用户刚填的明文,直接用 + } + a, err := aead() + if err != nil { + return "", err + } + raw, err := base64.RawURLEncoding.DecodeString(stored[len(cipherPrefix):]) + if err != nil { + return "", err + } + ns := a.NonceSize() + if len(raw) < ns { + return "", errors.New("secrets: 密文长度不足") + } + plain, err := a.Open(nil, raw[:ns], raw[ns:], nil) + if err != nil { + return "", err // 密钥不匹配 / 密文被篡改 + } + return string(plain), nil +} + +// IsEncrypted 报告一个值是否为本包产出的密文。 +func IsEncrypted(s string) bool { + return len(s) > len(cipherPrefix) && s[:len(cipherPrefix)] == cipherPrefix +} + +// MustHaveKeyInProd 在生产模式(APP_ENV=production/prod 或 GIN_MODE=release)下, +// 若未设置 SUNDYNIX_SECRET_KEY 则直接 fatal——杜绝用开发默认密钥加密(形同明文)。 +// 各处理 api_key 的服务(gateway/dispatcher/mcp-go)应在启动时调用;且必须配置相同的密钥。 +func MustHaveKeyInProd() { + if os.Getenv("SUNDYNIX_SECRET_KEY") != "" { + return + } + env := strings.ToLower(os.Getenv("APP_ENV")) + if env == "production" || env == "prod" || strings.ToLower(os.Getenv("GIN_MODE")) == "release" { + log.Fatal("[secrets] 生产模式必须设置 SUNDYNIX_SECRET_KEY(32+ 字节强随机),且各服务一致;拒绝以开发默认密钥加密") + } +} diff --git a/sundynix-shared/secrets/secrets_test.go b/sundynix-shared/secrets/secrets_test.go new file mode 100644 index 0000000..da430e3 --- /dev/null +++ b/sundynix-shared/secrets/secrets_test.go @@ -0,0 +1,79 @@ +package secrets + +import ( + "sync" + "testing" +) + +// resetForTest 清空惰性缓存的 AEAD,让后续调用按当前环境变量重新派生密钥。 +func resetForTest() { + gcmOnce = sync.Once{} + gcm = nil + gcmErr = nil +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + t.Setenv("SUNDYNIX_SECRET_KEY", "unit-test-key") + resetForTest() + + plain := "sk-912cf85b16d04b22bcb95f4576423bfb" + ct, err := Encrypt(plain) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if !IsEncrypted(ct) { + t.Fatalf("expected ciphertext prefix, got %q", ct) + } + if ct == plain { + t.Fatal("ciphertext must differ from plaintext") + } + got, err := Decrypt(ct) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if got != plain { + t.Fatalf("round trip mismatch: got %q want %q", got, plain) + } +} + +func TestEncryptEmptyIsNoop(t *testing.T) { + resetForTest() + ct, err := Encrypt("") + if err != nil || ct != "" { + t.Fatalf("empty should stay empty: %q %v", ct, err) + } +} + +func TestDecryptLegacyPlaintextPassthrough(t *testing.T) { + resetForTest() + // 历史明文行无 enc: 前缀,应原样返回(平滑迁移)。 + got, err := Decrypt("plain-legacy-key") + if err != nil { + t.Fatalf("legacy passthrough errored: %v", err) + } + if got != "plain-legacy-key" { + t.Fatalf("legacy passthrough mismatch: %q", got) + } +} + +func TestNonceRandomized(t *testing.T) { + t.Setenv("SUNDYNIX_SECRET_KEY", "unit-test-key") + resetForTest() + a, _ := Encrypt("same-input") + b, _ := Encrypt("same-input") + if a == b { + t.Fatal("two encryptions of same input must differ (random nonce)") + } +} + +func TestWrongKeyFailsClosed(t *testing.T) { + t.Setenv("SUNDYNIX_SECRET_KEY", "key-A") + resetForTest() + ct, _ := Encrypt("secret") + + t.Setenv("SUNDYNIX_SECRET_KEY", "key-B") + resetForTest() + if _, err := Decrypt(ct); err == nil { + t.Fatal("decrypt with wrong key must error, not silently succeed") + } +}