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:
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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+ 字节强随机),且各服务一致;拒绝以开发默认密钥加密")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user