Files
ModelRouter/internal/lua/secrets_crypto_test.go
JianFeeeee 6fe6014d92 chore: 清理 emoji 与修正发布脚本仓库名
产品代码统一为纯文本风格(照参考实现),去掉 emoji:billing/quota/usage 插件、
channels 的提示文案、WebUI 文案,以及 go 测试、docs、packaging 脚本中的
注意/OK/FAIL 等符号化写法。第三方目录(vendor、node_modules、.probe)不动。

另:packaging/upload-release.sh 的仓库名改为真实全名
JianFeeeee/ModelRouter → HeNanMU-EEA/ModelRouter(前者是重定向,POST/PUT 不跟随
会 404)。
2026-10-13 13:42:57 +08:00

228 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}