Files
Blizzard 46ef3df221 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>
2026-06-24 10:36:34 +08:00

118 lines
4.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_KEY32+ 字节强随机),且各服务一致;拒绝以开发默认密钥加密")
}
}