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:
@@ -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