// 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+ 字节强随机),且各服务一致;拒绝以开发默认密钥加密") } }