mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
test: 四类验收 —— 加密解密 / 变异 / 压力 / 旧通路
用户要求的四类测试全部落地,且每类都抓到了真问题: ## 0) 测试暴露的真实缺陷(先修后测) **保险库明文落盘**(加密解密测试的目标缺陷):.secrets/<plugin>.json 此前 是明文 JSON。接入 SecretBox(与 config.yaml 同一把 master key、同 enc:v1 形态):SetSecretBox 注入(secretSealer 接口,测试可桩);写盘逐值加密、 读盘逐值解密;无前缀旧值原样通过 —— 旧明文库可读、下次写整体重封。 core 注入点在 store 创建之后(NewPlugins 时 box 还不存在 —— 顺序 load-bearing, 修掉了一个 nil-deref)。 ## 1) 加密解密(internal/lua/secrets_crypto_test.go) - EncryptedAtRest:磁盘字节不含明文 token、带 enc:v1、文件 0600/目录 0700 - PlaintextFallbackAndUpgrade:旧明文库可读 + 任意一次写整体重封为密文 - WrongMasterKey:换 key 后读库必须报错(静默空会让插件重登覆盖凭据) - ConcurrentPlugins:8 插件×2 goroutine 并发读写,无串台/丢写/文件数正确 ## 2) 变异(internal/core/device_mutate_test.go + device_harness_test.go) - 决策表抽成 deviceHarness.runDecisionTable(行为测试与变异测试共用, 单一事实源) - 5 个语义变异逐个杀:slow_down 不加 interval / denied 不识别 / expired_token 不识别 / authorization_pending 当失败 / 成功分支禁用 - 行3/行4 断言 message 区分(用户拒绝/过期)—— 初版只断 state=failed 时 两个变异体幸存(落进通用 failed 分支),加 message 后全杀。 这正是变异测试的价值:暴露断言太弱。 - 基准(未变异)必须全过 —— 防止测试自身失效的假阳性 ## 3) 压力(internal/core/channels_stress_test.go) - ConcurrentLogins:12 轮并发 device-poll,会话互不串台、全部终态 - ClaimStress:30 并发领签到,上游收到恰 30 次(无登录态丢失) ## 4) 旧通路(channels_stress_test.go 后半 + 复用 lua 包测试) - LegacyVaultPlaintextLoad:老版本明文库 → 新版本可读 → 下次写重封 - LegacySourceSealedConfig:明文 api_key 的旧 config.yaml → 运行期解密、 落盘 seal、master.key 0600 生成 全量 lua/core/gateway/types/scheduler/provider/config 测试通过;build+vet 绿。
This commit is contained in:
@ -43,6 +43,24 @@ import (
|
||||
// secretsDirName is the vault directory inside plugin_dir. The leading dot keeps
|
||||
// it out of a casual `ls` of the plugin directory, which is where operators look
|
||||
// for "what plugins do I have" — the vault is not a plugin.
|
||||
// secretSealer 是保险库落盘加密所需的最小能力面。
|
||||
//
|
||||
// 不直接 import config.SecretBox 的原因与 KernelAPI 相同:保持依赖注入的缝,
|
||||
// 测试可以用桩;core 侧把 *config.SecretBox 注进来(Encrypt/Decrypt 语义与
|
||||
// config.yaml 的凭据完全一致 —— 同一把 master key、同一种 enc:v1 前缀)。
|
||||
type secretSealer interface {
|
||||
Encrypt(plain string) (string, error)
|
||||
Decrypt(v string) (string, error)
|
||||
}
|
||||
|
||||
// SetSecretBox 注入落盘加密器。必须在任何插件写凭据之前调用(core 启动序里
|
||||
// 在 NewPlugins 之后立即做)。传 nil 合法(=不加密,测试环境)。
|
||||
func (ps *Plugins) SetSecretBox(box secretSealer) {
|
||||
ps.mu.Lock()
|
||||
ps.secretBox = box
|
||||
ps.mu.Unlock()
|
||||
}
|
||||
|
||||
const secretsDirName = ".secrets"
|
||||
|
||||
// secretsDir returns the vault directory (empty when there is no plugin dir, in
|
||||
@ -105,6 +123,18 @@ func (ps *Plugins) loadSecretsLocked(plugin string) (map[string]string, error) {
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, fmt.Errorf("vault for plugin %s is unreadable: %w", plugin, err)
|
||||
}
|
||||
// Decrypt each value. Decrypt passes values without the enc:v1 prefix through
|
||||
// unchanged, so vaults written by an older plaintext build keep loading —
|
||||
// and get re-sealed on the next write (SetSecret rewrites the whole file).
|
||||
if ps.secretBox != nil {
|
||||
for k, v := range m {
|
||||
plain, err := ps.secretBox.Decrypt(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault for plugin %s: key %q: %w (master key changed?)", plugin, k, err)
|
||||
}
|
||||
m[k] = plain
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@ -126,7 +156,23 @@ func (ps *Plugins) writeSecretsLocked(plugin string, m map[string]string) error
|
||||
// vault created before this mode was enforced would keep its old permissions.
|
||||
_ = os.Chmod(dir, 0o700)
|
||||
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
// Encrypt-at-rest: each VALUE is sealed with the deployment's master key
|
||||
// (same enc:v1 form as config.yaml credentials). Keys stay plaintext so the
|
||||
// file stays diffable/debuggable without exposing token bytes. A nil box
|
||||
// writes plaintext — tests only; core always injects a real box.
|
||||
sealed := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
if ps.secretBox != nil {
|
||||
enc, err := ps.secretBox.Encrypt(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal secret %q: %w", k, err)
|
||||
}
|
||||
sealed[k] = enc
|
||||
} else {
|
||||
sealed[k] = v
|
||||
}
|
||||
}
|
||||
b, err := json.MarshalIndent(sealed, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -308,6 +308,12 @@ type Plugins struct {
|
||||
// through this interface, never through a direct import (core depends on
|
||||
// lua, not the other way round).
|
||||
kernelAPI KernelAPI
|
||||
// secretBox seals the credential vault at rest. nil = write plaintext (tests
|
||||
// / embedders with no runtime dir). Injected by core via SetSecretBox: it is
|
||||
// the deployment's ONE master key, the same one that seals config.yaml, so a
|
||||
// lost master.key loses the plugin tokens too — loud, consistent, not a
|
||||
// second key to manage.
|
||||
secretBox secretSealer
|
||||
// kernelPerms records what the OPERATOR granted each plugin. Separate from
|
||||
// what a plugin DECLARES it wants (Plugin.KernelDeclared): a declaration is
|
||||
// a request, a grant is a privilege. Persisted in .access.json.
|
||||
|
||||
227
internal/lua/secrets_crypto_test.go
Normal file
227
internal/lua/secrets_crypto_test.go
Normal file
@ -0,0 +1,227 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- 测试用加密桩:与 config.SecretBox 同语义(AEAD + enc:v1 前缀)----
|
||||
|
||||
const testEncPrefix = "enc:v1:"
|
||||
|
||||
// realBox:真 AEAD-GCM(与 config.SecretBox 同原语),行为一致。
|
||||
type realBox struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
func newRealBox(t *testing.T) *realBox {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256([]byte("test-master-key"))
|
||||
block, err := aes.NewCipher(digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &realBox{aead: aead}
|
||||
}
|
||||
|
||||
func (b *realBox) Encrypt(plain string) (string, error) {
|
||||
nonce := make([]byte, b.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := b.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return testEncPrefix + b64RawURL(sealed), nil
|
||||
}
|
||||
|
||||
func (b *realBox) Decrypt(v string) (string, error) {
|
||||
if !strings.HasPrefix(v, testEncPrefix) {
|
||||
return v, nil
|
||||
}
|
||||
raw, err := b64RawURLDecode(strings.TrimPrefix(v, testEncPrefix))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
n := b.aead.NonceSize()
|
||||
if len(raw) < n {
|
||||
return v, os.ErrInvalid
|
||||
}
|
||||
plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// failBox 永远解密失败(模拟 master.key 被换/丢失)。
|
||||
type failBox struct{}
|
||||
|
||||
func (failBox) Encrypt(plain string) (string, error) { return testEncPrefix + "AAAA", nil }
|
||||
func (failBox) Decrypt(v string) (string, error) {
|
||||
if !strings.HasPrefix(v, testEncPrefix) {
|
||||
return v, nil
|
||||
}
|
||||
return v, os.ErrPermission
|
||||
}
|
||||
|
||||
func b64RawURL(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
func b64RawURLDecode(s string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// TestSecretVaultEncryptedAtRest 验证保险库落盘是密文(enc:v1 前缀),
|
||||
// 且读回时解密为原值。明文 token 绝不能出现在磁盘字节里。
|
||||
func TestSecretVaultEncryptedAtRest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(nil, dir)
|
||||
ps.SetSecretBox(newRealBox(t))
|
||||
|
||||
if err := ps.SetSecret("channels", "channels:minimax", "SUPER-TOKEN-123"); err != nil {
|
||||
t.Fatalf("SetSecret: %v", err)
|
||||
}
|
||||
|
||||
// 磁盘字节必须不含明文
|
||||
raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "SUPER-TOKEN-123") {
|
||||
t.Fatalf("★ 保险库明文落盘! 文件内容含 token:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), testEncPrefix) {
|
||||
t.Fatalf("落盘值应带 %s 前缀(统一密文形态):\n%s", testEncPrefix, raw)
|
||||
}
|
||||
|
||||
// 读回是原值
|
||||
v, ok, err := ps.GetSecret("channels", "channels:minimax")
|
||||
if err != nil || !ok || v != "SUPER-TOKEN-123" {
|
||||
t.Fatalf("round-trip 失败: v=%q ok=%v err=%v", v, ok, err)
|
||||
}
|
||||
|
||||
// 文件权限 0600、目录 0700
|
||||
if fi, err := os.Stat(filepath.Join(dir, ".secrets", "channels.json")); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("vault 文件权限应 0600, 实得 %v (err=%v)", fi.Mode(), err)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(dir, ".secrets")); err != nil || fi.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("vault 目录权限应 0700, 实得 %v (err=%v)", fi.Mode(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretVaultPlaintextFallbackAndUpgrade:旧明文库可读(向后兼容),
|
||||
// 且下一次写会整体升级为密文。
|
||||
func TestSecretVaultPlaintextFallbackAndUpgrade(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(nil, dir)
|
||||
// 1) 无 box 时写入 = 旧版本形态(明文)
|
||||
if err := ps.SetSecret("channels", "k", "OLD-PLAIN"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2) 换上有 box 的新版本:旧明文必须能读出来
|
||||
ps.SetSecretBox(newRealBox(t))
|
||||
v, ok, err := ps.GetSecret("channels", "k")
|
||||
if err != nil || !ok || v != "OLD-PLAIN" {
|
||||
t.Fatalf("旧明文读回应原值: v=%q ok=%v err=%v", v, ok, err)
|
||||
}
|
||||
// 3) 任意一次写(哪怕写别的键)会把整个文件升级为密文
|
||||
if err := ps.SetSecret("channels", "k2", "NEW"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json"))
|
||||
if strings.Contains(string(raw), "OLD-PLAIN") {
|
||||
t.Fatalf("★ 升级写后旧明文值仍在盘上:\n%s", raw)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(m["k"], testEncPrefix) {
|
||||
t.Fatalf("旧值应被重封为密文: %v", m["k"])
|
||||
}
|
||||
// 4) 两个值都还能读回
|
||||
for key, want := range map[string]string{"k": "OLD-PLAIN", "k2": "NEW"} {
|
||||
if v, _, _ := ps.GetSecret("channels", key); v != want {
|
||||
t.Fatalf("升级后 %q: got %q want %q", key, v, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretVaultWrongMasterKey:master.key 换掉后读库必须**报错**而不是静默
|
||||
// 返回空 —— 空会让插件重新登录并覆盖文件,把可恢复问题变成凭据丢失。
|
||||
func TestSecretVaultWrongMasterKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(nil, dir)
|
||||
ps.SetSecretBox(newRealBox(t))
|
||||
if err := ps.SetSecret("channels", "k", "SECRET"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 换一把钥匙(模拟 master.key 丢失重建)
|
||||
ps.SetSecretBox(failBox{})
|
||||
if _, _, err := ps.GetSecret("channels", "k"); err == nil {
|
||||
t.Fatal("★ 换 master key 后读库未报错 —— 会静默吞凭据")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 并发压力:多插件同时读写各自的库 ----
|
||||
|
||||
// TestSecretVaultConcurrentPlugins 压力:8 个插件 × 每插件 2 goroutine 并发
|
||||
// set/get/delete 自己的键。断言:无数据串台(跨插件不可见)、无丢写、文件数正确。
|
||||
func TestSecretVaultConcurrentPlugins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(nil, dir)
|
||||
ps.SetSecretBox(newRealBox(t))
|
||||
|
||||
const plugins = 8
|
||||
const perG = 50
|
||||
var wg sync.WaitGroup
|
||||
for p := 0; p < plugins; p++ {
|
||||
name := "plug" + string(rune('A'+p))
|
||||
for g := 0; g < 2; g++ {
|
||||
wg.Add(1)
|
||||
go func(name string, g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perG; i++ {
|
||||
key := name + ":k" + string(rune('a'+g))
|
||||
if err := ps.SetSecret(name, key, name+"-token"); err != nil {
|
||||
t.Errorf("SetSecret(%s,%s): %v", name, key, err)
|
||||
return
|
||||
}
|
||||
v, ok, err := ps.GetSecret(name, key)
|
||||
if err != nil || !ok || v != name+"-token" {
|
||||
t.Errorf("GetSecret(%s,%s)=%q ok=%v err=%v", name, key, v, ok, err)
|
||||
return
|
||||
}
|
||||
// 跨插件必须不可见
|
||||
other := "plug" + string(rune('A'+(p+1)%plugins))
|
||||
if _, ok, _ := ps.GetSecret(other, key); ok {
|
||||
t.Errorf("★ 凭据串台: %s 的键在 %s 可见", name, other)
|
||||
return
|
||||
}
|
||||
if g == 1 && i%10 == 9 {
|
||||
_ = ps.DeleteSecret(name, key)
|
||||
}
|
||||
}
|
||||
}(name, g)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(dir, ".secrets"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) > plugins {
|
||||
t.Errorf("vault 文件数 %d 超过插件数 %d(应一插件一文件)", len(entries), plugins)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user