feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
+11
-7
@@ -108,18 +108,22 @@ Grafana Dashboard 体系:
|
|||||||
|
|
||||||
## 二、安全加固 (Security Hardening)
|
## 二、安全加固 (Security Hardening)
|
||||||
|
|
||||||
### 2.1 密钥管理 — 🔴 高优先
|
### 2.1 密钥管理 — ⚠️ 部分(API Key 加密已落地)
|
||||||
|
|
||||||
**现状**:
|
**已实现**:
|
||||||
- `ModelConfig.APIKey` 明文存 PostgreSQL、经 NATS 明文广播到各消费方
|
- ✅ **LLM `api_key` 加密存储 + 端到端密文**:`sundynix-shared/secrets`(AES-256-GCM,密钥由 `SUNDYNIX_SECRET_KEY` 经 SHA-256 派生)。
|
||||||
- JWT 签名密钥硬编码或环境变量
|
网关保存时加密落库(`enc:1:` 前缀),密文经 NATS 原样下发,消费方(dispatcher/mcp-go)在 bus 层解密——
|
||||||
- 配置文件中明文 DSN(含密码)
|
**磁盘与线缆上均无明文**,仅在构建 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 |
|
| **K8s Secrets + CSI** | 集群内密钥挂载 | Secrets Store CSI Driver + Vault Provider |
|
||||||
| **NATS TLS** | NATS 客户端↔服务端全链路 TLS,消息传输加密 | 配置 nats-server TLS + 客户端证书 |
|
| **NATS TLS** | NATS 客户端↔服务端全链路 TLS,消息传输加密 | 配置 nats-server TLS + 客户端证书 |
|
||||||
| **PG SSL** | 数据库连接启用 `sslmode=require` | 当前 DSN 为 `sslmode=disable` |
|
| **PG SSL** | 数据库连接启用 `sslmode=require` | 当前 DSN 为 `sslmode=disable` |
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import (
|
|||||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||||||
dnats "github.com/sundynix/sundynix-dispatcher/internal/nats"
|
dnats "github.com/sundynix/sundynix-dispatcher/internal/nats"
|
||||||
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文
|
||||||
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
||||||
|
|
||||||
pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群
|
pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
"github.com/sundynix/sundynix-gateway/internal/router"
|
"github.com/sundynix/sundynix-gateway/internal/router"
|
||||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
"github.com/sundynix/sundynix-shared/contract"
|
"github.com/sundynix/sundynix-shared/contract"
|
||||||
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以加密落库的 api_key
|
||||||
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
||||||
pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable")
|
pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable")
|
||||||
redisAddr := envOr("REDIS_ADDR", "localhost:6379")
|
redisAddr := envOr("REDIS_ADDR", "localhost:6379")
|
||||||
|
|||||||
@@ -5,14 +5,33 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
"github.com/sundynix/sundynix-shared/contract"
|
"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 + 测试连接 + 变更广播。
|
// 控制面(运维管理):LLM 模型配置 CRUD + 测试连接 + 变更广播。
|
||||||
// 表 sundynix_model 由 Gateway 持有;Dispatcher 经 NATS 取激活配置。
|
// 表 sundynix_model 由 Gateway 持有;Dispatcher 经 NATS 取激活配置。
|
||||||
|
|
||||||
@@ -34,9 +53,10 @@ func (h *Handler) ListModels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(rows))
|
out := make([]gin.H, 0, len(rows))
|
||||||
for _, m := range rows {
|
for _, m := range rows {
|
||||||
|
plain, _ := secrets.Decrypt(m.APIKey) // 库内为密文,脱敏前先还原以展示真实尾 4 位
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"id": m.ID, "kind": m.Kind, "provider": m.Provider, "base_url": m.BaseURL,
|
"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})
|
c.JSON(http.StatusOK, gin.H{"models": out})
|
||||||
@@ -99,7 +119,19 @@ func (h *Handler) SaveModel(c *gin.Context) {
|
|||||||
if kind == "" {
|
if kind == "" {
|
||||||
kind = contract.ConfigKindChat
|
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 {
|
if err := h.db.SaveModel(c.Request.Context(), m); err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -137,16 +169,14 @@ func (h *Handler) TestModel(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 若传了已存的 id 但未带 key,用库里的真实 key。
|
// 若未带 key(或回传脱敏占位),用库里的真实 key。
|
||||||
key := b.APIKey
|
key := b.APIKey
|
||||||
if key == "" && b.ID != "" {
|
if key == "" || strings.HasPrefix(key, maskPrefix) {
|
||||||
if rows, _ := h.db.ListModels(c.Request.Context(), ""); rows != nil {
|
key = h.existingModelKey(c.Request.Context(), b.ID)
|
||||||
for _, m := range rows {
|
}
|
||||||
if m.ID == b.ID {
|
// key 此刻可能是库内密文,也可能是用户新填的明文;Decrypt 对无前缀明文透传,两种都还原成可用明文。
|
||||||
key = m.APIKey
|
if plain, err := secrets.Decrypt(key); err == nil {
|
||||||
}
|
key = plain
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -285,7 +285,10 @@ func (p *Postgres) SaveModel(ctx context.Context, m *LLMModel) error {
|
|||||||
if m.ID == "" {
|
if m.ID == "" {
|
||||||
return p.db.WithContext(ctx).Create(m).Error
|
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 唯一激活。
|
// SetActiveModel 把指定模型设为激活(同 kind 内其余取消),事务保证每 kind 唯一激活。
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import (
|
|||||||
"github.com/sundynix/sundynix-mcp-go/internal/memory"
|
"github.com/sundynix/sundynix-mcp-go/internal/memory"
|
||||||
"github.com/sundynix/sundynix-mcp-go/internal/rag"
|
"github.com/sundynix/sundynix-mcp-go/internal/rag"
|
||||||
"github.com/sundynix/sundynix-mcp-go/internal/search"
|
"github.com/sundynix/sundynix-mcp-go/internal/search"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文
|
||||||
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
||||||
pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable")
|
pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable")
|
||||||
redisAddr := envOr("REDIS_ADDR", "localhost:6379")
|
redisAddr := envOr("REDIS_ADDR", "localhost:6379")
|
||||||
@@ -41,7 +44,7 @@ func main() {
|
|||||||
log.Printf("[mcp_go] connected %s", natsURL)
|
log.Printf("[mcp_go] connected %s", natsURL)
|
||||||
|
|
||||||
engine := search.NewHybrid() // LLM Wiki 混合检索:Bleve + Milvus + Neo4j
|
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()
|
defer mem.Close()
|
||||||
hist := history.Open(redisAddr) // 会话短期历史:Redis(连不上则降级)
|
hist := history.Open(redisAddr) // 会话短期历史:Redis(连不上则降级)
|
||||||
defer hist.Close()
|
defer hist.Close()
|
||||||
|
|||||||
@@ -12,8 +12,20 @@ import (
|
|||||||
"github.com/nats-io/nats.go/jetstream"
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
"github.com/sundynix/sundynix-shared/contract"
|
"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 上下文。
|
// Bus 持有 NATS 连接与 JetStream 上下文。
|
||||||
type Bus struct {
|
type Bus struct {
|
||||||
nc *nats.Conn
|
nc *nats.Conn
|
||||||
@@ -281,6 +293,7 @@ func (b *Bus) RequestConfig(ctx context.Context, kind string) (*contract.ModelCo
|
|||||||
if !cfg.Ready() {
|
if !cfg.Ready() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
decryptConfig(&cfg) // 线缆上是密文,消费侧还原
|
||||||
return &cfg, nil
|
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) {
|
sub, err := b.nc.Subscribe(contract.ConfigUpdatedSubject(kind), func(m *nats.Msg) {
|
||||||
var cfg contract.ModelConfig
|
var cfg contract.ModelConfig
|
||||||
if json.Unmarshal(m.Data, &cfg) == nil {
|
if json.Unmarshal(m.Data, &cfg) == nil {
|
||||||
|
decryptConfig(&cfg) // 线缆上是密文,消费侧还原
|
||||||
onUpdate(&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