mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 07:34:13 +00:00
feat: 密钥静态加密 + /api/v1 agent 管理 API
密钥加密(写侧封存 / 读侧解封)
- config.yaml 的 sources[].api_key、sources[].headers、keys[].key 落盘即
AES-256-GCM 密文(enc:v1: 前缀),master.key 复用 runtime store 那把
- 内存里永远是明文:鉴权比对、API 返回新建 key、WebUI 编辑回填都不受影响
- 启动时一次性封存现存明文(幂等,已封存则不写盘);-check 不写文件
- UpsertSourceInYAML 增加 box 参数,新加的源不再以明文落盘
- 解密失败改为硬错误:原先 MustDecrypt 返回密文会被下次 Save 二次封存
(实测:源 key 18→20、静默损坏),现在启动即失败且配置分毫不动
/api/v1:面向 agent 的管理 API(WebUI 零影响)
- GET /api/v1 机器可读索引,列出每个端点的方法/权限/用途
- GET /api/v1/overview 一次调用看全貌:源 + AUTO 链 + 密钥数 + 健康度
- GET /api/v1/health 仅健康快照
- GET /api/v1/models 按源分组的可路由模型清单
- GET /api/v1/sources[/{name}] 凭据遮蔽后的源
- GET /api/v1/auto 调度链与实时槽位状态
- GET /api/v1/keys admin only,密钥元数据,绝不回显密钥本身
- 沿用同一套网关 key 鉴权;读端点任意角色,写仍需 admin
测试:15 个新用例(含负向:泄密、越权、写操作必须被拒)
变异验证:maskKey 不遮蔽→红、去掉 admin 校验→红
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -27,6 +27,11 @@ type Config struct {
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
// box seals credentials (sources' api_key/headers, keys' key) at rest.
|
||||
// In-memory values are always plaintext; only the bytes on disk are sealed.
|
||||
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
|
||||
// filesystem-free.
|
||||
box *SecretBox
|
||||
}
|
||||
|
||||
// Defaults applied to any source (YAML or runtime) that leaves a field unset.
|
||||
@ -192,10 +197,15 @@ func RemoveSourceFromYAML(path, name string) error {
|
||||
|
||||
// UpsertSourceInYAML adds or updates a source entry in the YAML config file.
|
||||
// Uses yaml.Node to preserve the rest of the file's comments and formatting.
|
||||
func UpsertSourceInYAML(path, name string, src Source) error {
|
||||
// When box is non-nil the source's credentials are sealed before writing, so a
|
||||
// newly added source never lands in the file as plaintext.
|
||||
func UpsertSourceInYAML(path, name string, src Source, box *SecretBox) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("config path is empty")
|
||||
}
|
||||
if err := sealSource(&src, box); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -253,11 +263,17 @@ func UpsertSourceInYAML(path, name string, src Source) error {
|
||||
|
||||
// Save writes the current config back to the YAML file (preserving comments
|
||||
// via yaml.Node round-trip when possible, or full marshaling as fallback).
|
||||
// Credentials are sealed on the way out and the in-memory copy is restored to
|
||||
// plaintext afterwards, so callers keep working with usable values.
|
||||
func (c *Config) Save() error {
|
||||
if c.Path == "" {
|
||||
return fmt.Errorf("config path is empty")
|
||||
}
|
||||
if err := c.sealInPlace(c.box); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := yaml.Marshal(c)
|
||||
c.unsealAfterWrite(c.box)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
227
internal/config/secret_config.go
Normal file
227
internal/config/secret_config.go
Normal file
@ -0,0 +1,227 @@
|
||||
package config
|
||||
|
||||
// Secret handling for config.yaml.
|
||||
//
|
||||
// config.yaml is 0644 world-readable by design (ops need to inspect it), so any
|
||||
// credential in it must not sit there in plaintext. Sources' api_key / headers
|
||||
// and gateway keys are therefore sealed at rest with the same SecretBox used by
|
||||
// the runtime store, and unsealed in memory at load time.
|
||||
//
|
||||
// The invariant that makes this safe: **in-memory values are always plaintext**,
|
||||
// and the enc:v1: prefix is what marks a file value as sealed. Read paths that
|
||||
// predate this (core.resolveSourceKey) already unseal, so only the write side and
|
||||
// the load-time normalize step are new.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sealSource seals one source's credentials in place (used by the YAML upsert
|
||||
// path, which is a package function and therefore has no Config to borrow a box
|
||||
// from).
|
||||
func sealSource(s *Source, box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Encrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
for k, v := range s.Headers {
|
||||
if v == "" || strings.HasPrefix(v, encPrefix) {
|
||||
continue
|
||||
}
|
||||
e, err := box.Encrypt(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Headers[k] = e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSecrets unseals every credential in the freshly parsed config so the
|
||||
// rest of the program only ever sees plaintext. A value without the enc:v1:
|
||||
// prefix is left untouched, which keeps hand-written plaintext configs working
|
||||
// (and is what a pre-encryption config file looks like).
|
||||
//
|
||||
// A value that carries the prefix but fails to decrypt is a hard error, not
|
||||
// something to paper over: returning the ciphertext (MustDecrypt's behavior)
|
||||
// would let the next Save re-seal it and turn one bad value into permanent,
|
||||
// compounding corruption. Losing the master key must be loud.
|
||||
func (c *Config) normalizeSecrets(box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Sources {
|
||||
s := &c.Sources[i]
|
||||
if strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Decrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source %q api_key: %w", s.Name, err)
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
for k, v := range s.Headers {
|
||||
if strings.HasPrefix(v, encPrefix) {
|
||||
d, err := box.Decrypt(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source %q header %q: %w", s.Name, k, err)
|
||||
}
|
||||
s.Headers[k] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range c.Keys {
|
||||
if strings.HasPrefix(c.Keys[i].Key, encPrefix) {
|
||||
v, err := box.Decrypt(c.Keys[i].Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway key %q: %w", c.Keys[i].Name, err)
|
||||
}
|
||||
c.Keys[i].Key = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sealInPlace replaces plaintext credentials with ciphertext for writing. It is
|
||||
// deliberately a separate step from Marshal: callers that need the plaintext
|
||||
// (auth comparisons, log output, returning a key to the operator who just
|
||||
// created it) must not be handed a sealed config by accident.
|
||||
func (c *Config) sealInPlace(box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Sources {
|
||||
s := &c.Sources[i]
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Encrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
if len(s.Headers) > 0 {
|
||||
sealed := make(map[string]string, len(s.Headers))
|
||||
for k, v := range s.Headers {
|
||||
if v == "" || strings.HasPrefix(v, encPrefix) {
|
||||
sealed[k] = v
|
||||
continue
|
||||
}
|
||||
e, err := box.Encrypt(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sealed[k] = e
|
||||
}
|
||||
s.Headers = sealed
|
||||
}
|
||||
}
|
||||
for i := range c.Keys {
|
||||
k := &c.Keys[i]
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
v, err := box.Encrypt(k.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k.Key = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsealAfterWrite restores plaintext after a sealed marshal so the live process
|
||||
// keeps working on plaintext values (mirrors Store.persistLocked's dance).
|
||||
func (c *Config) unsealAfterWrite(box *SecretBox) {
|
||||
// Save just encrypted every value it can see, so a failure here is
|
||||
// impossible; ignore the error rather than panic in a write path.
|
||||
_ = c.normalizeSecrets(box)
|
||||
}
|
||||
|
||||
// hasPlaintextSecrets reports whether any credential in the config is still in
|
||||
// the clear. Used to decide whether a startup migration write is needed, and to
|
||||
// warn (without leaking values) when no master key is available.
|
||||
func (c *Config) hasPlaintextSecrets() bool {
|
||||
for _, s := range c.Sources {
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
return true
|
||||
}
|
||||
for _, v := range s.Headers {
|
||||
if v != "" && !strings.HasPrefix(v, encPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range c.Keys {
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countPlaintextSecrets returns how many credentials are still in the clear, for
|
||||
// an operator-facing migration log line that must not print the values.
|
||||
func (c *Config) countPlaintextSecrets() int {
|
||||
n := 0
|
||||
for _, s := range c.Sources {
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
n++
|
||||
}
|
||||
for _, v := range s.Headers {
|
||||
if v != "" && !strings.HasPrefix(v, encPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range c.Keys {
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// NormalizeSecretsForRun unseals the loaded config and then seals it back on
|
||||
// disk if anything was still in the clear. Order matters: Load() read the file
|
||||
// with ciphertext still in place, so the unseal has to happen before the
|
||||
// registry (and any Save the startup path performs) sees the values.
|
||||
func (c *Config) NormalizeSecretsForRun(box *SecretBox) error {
|
||||
c.AttachSecretBox(box)
|
||||
if err := c.normalizeSecrets(box); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.migratePlaintextSecrets()
|
||||
}
|
||||
|
||||
// AttachSecretBox wires the encryption box into the config so Save can seal
|
||||
// credentials. Kept as an explicit call (rather than a constructor argument) so
|
||||
// config.Load stays usable in contexts that have no filesystem secrets (tests,
|
||||
// `-check`).
|
||||
func (c *Config) AttachSecretBox(box *SecretBox) { c.box = box }
|
||||
|
||||
// SecretBox returns the wired encryption box, or nil when none is attached.
|
||||
func (c *Config) SecretBox() *SecretBox { return c.box }
|
||||
|
||||
// migratePlaintextSecrets seals any credential still in the clear and writes the
|
||||
// file once. It is idempotent: a config that is already sealed (or has no
|
||||
// secrets) is left alone and nothing is written.
|
||||
func (c *Config) migratePlaintextSecrets() error {
|
||||
if c.box == nil || c.Path == "" {
|
||||
return nil
|
||||
}
|
||||
if !c.hasPlaintextSecrets() {
|
||||
return nil
|
||||
}
|
||||
n := c.countPlaintextSecrets()
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path)
|
||||
return nil
|
||||
}
|
||||
283
internal/config/secret_config_test.go
Normal file
283
internal/config/secret_config_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeMasterKey plants a deterministic master.key next to the given runtime
|
||||
// file so tests exercise the real box instead of the env-var shortcut.
|
||||
func writeMasterKey(t *testing.T, runtimeFile string) *SecretBox {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(runtimeFile), "master.key"),
|
||||
[]byte(strings.Repeat("ab", 32)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box, err := NewSecretBox(runtimeFile)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSecretBox: %v", err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
func TestSaveSealsCredentialsAndLoadUnseals(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
runtime := filepath.Join(dir, "runtime.json")
|
||||
box := writeMasterKey(t, runtime)
|
||||
|
||||
cfg := &Config{
|
||||
Path: path,
|
||||
Listen: "127.0.0.1:0",
|
||||
Sources: []Source{{
|
||||
Name: "up",
|
||||
BaseURL: "http://up/v1",
|
||||
APIKey: "sk-plaintext-secret",
|
||||
Adapter: "openai",
|
||||
Headers: map[string]string{"X-Extra": "header-secret"},
|
||||
Models: []Model{{ID: "m", Kind: "chat"}},
|
||||
}},
|
||||
Keys: []GWKey{{Key: "sk-gw-plain", Role: "admin", Name: "admin"}},
|
||||
}
|
||||
cfg.AttachSecretBox(box)
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
if strings.Contains(text, "sk-plaintext-secret") {
|
||||
t.Error("source api_key written in plaintext")
|
||||
}
|
||||
if strings.Contains(text, "header-secret") {
|
||||
t.Error("source header written in plaintext")
|
||||
}
|
||||
if strings.Contains(text, "sk-gw-plain") {
|
||||
t.Error("gateway key written in plaintext")
|
||||
}
|
||||
if n := strings.Count(text, encPrefix); n != 3 {
|
||||
t.Errorf("expected 3 sealed values, found %d", n)
|
||||
}
|
||||
|
||||
// The live in-memory config must still hold plaintext after Save.
|
||||
if cfg.Sources[0].APIKey != "sk-plaintext-secret" {
|
||||
t.Errorf("in-memory api_key mutated by Save: %q", cfg.Sources[0].APIKey)
|
||||
}
|
||||
if cfg.Keys[0].Key != "sk-gw-plain" {
|
||||
t.Errorf("in-memory gateway key mutated by Save: %q", cfg.Keys[0].Key)
|
||||
}
|
||||
|
||||
// A fresh load must hand back plaintext again.
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
loaded.AttachSecretBox(box)
|
||||
loaded.normalizeSecrets(box)
|
||||
if loaded.Sources[0].APIKey != "sk-plaintext-secret" {
|
||||
t.Errorf("loaded api_key = %q, want plaintext", loaded.Sources[0].APIKey)
|
||||
}
|
||||
if loaded.Sources[0].Headers["X-Extra"] != "header-secret" {
|
||||
t.Errorf("loaded header = %q, want plaintext", loaded.Sources[0].Headers["X-Extra"])
|
||||
}
|
||||
if loaded.Keys[0].Key != "sk-gw-plain" {
|
||||
t.Errorf("loaded gateway key = %q, want plaintext", loaded.Keys[0].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextConfigStillLoads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
// A hand-written, pre-encryption config: no enc:v1: anywhere.
|
||||
body := `listen: 127.0.0.1:0
|
||||
sources:
|
||||
- name: legacy
|
||||
base_url: http://legacy/v1
|
||||
api_key: sk-written-by-hand
|
||||
adapter: openai
|
||||
models:
|
||||
- id: m
|
||||
kind: chat
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
|
||||
cfg.AttachSecretBox(box)
|
||||
cfg.normalizeSecrets(box)
|
||||
if cfg.Sources[0].APIKey != "sk-written-by-hand" {
|
||||
t.Errorf("plaintext config value changed: %q", cfg.Sources[0].APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratePlaintextSecretsIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
runtime := filepath.Join(dir, "runtime.json")
|
||||
box := writeMasterKey(t, runtime)
|
||||
|
||||
cfg := &Config{
|
||||
Path: path,
|
||||
Listen: "127.0.0.1:0",
|
||||
Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-clear", Adapter: "openai", Models: []Model{{ID: "m"}}}},
|
||||
}
|
||||
cfg.AttachSecretBox(box)
|
||||
|
||||
if err := cfg.migratePlaintextSecrets(); err != nil {
|
||||
t.Fatalf("first migrate: %v", err)
|
||||
}
|
||||
first, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(first), encPrefix) {
|
||||
t.Fatal("migration did not seal the value")
|
||||
}
|
||||
// In-memory must be plaintext so the running process keeps working.
|
||||
if cfg.Sources[0].APIKey != "sk-clear" {
|
||||
t.Errorf("in-memory api_key = %q, want plaintext", cfg.Sources[0].APIKey)
|
||||
}
|
||||
|
||||
// Second run: already sealed => no write.
|
||||
before, _ := os.Stat(path)
|
||||
if err := cfg.migratePlaintextSecrets(); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
after, _ := os.Stat(path)
|
||||
if before.ModTime() != after.ModTime() {
|
||||
t.Error("second migrate rewrote an already-sealed config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealedValueDoesNotDoubleEncrypt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
|
||||
cfg := &Config{Path: filepath.Join(dir, "config.yaml"), Sources: []Source{{Name: "a", BaseURL: "http://a"}}}
|
||||
cfg.AttachSecretBox(box)
|
||||
|
||||
once, err := box.Encrypt("sk-x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources[0].APIKey = once
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := box.Decrypt(cfg.Sources[0].APIKey)
|
||||
if err != nil {
|
||||
t.Fatalf("value became undecryptable: %v", err)
|
||||
}
|
||||
if got != "sk-x" {
|
||||
t.Errorf("round trip = %q, want sk-x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSourceInYAMLSealsCredentials(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
|
||||
|
||||
if err := os.WriteFile(path, []byte("listen: 127.0.0.1:0\nsources: []\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := Source{Name: "new", BaseURL: "http://new/v1", APIKey: "sk-fresh", Adapter: "openai", Models: []Model{{ID: "m"}}}
|
||||
if err := UpsertSourceInYAML(path, src.Name, src, box); err != nil {
|
||||
t.Fatalf("UpsertSourceInYAML: %v", err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(raw), "sk-fresh") {
|
||||
t.Error("newly added source stored its api_key in plaintext")
|
||||
}
|
||||
if !strings.Contains(string(raw), encPrefix) {
|
||||
t.Error("newly added source was not sealed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountPlaintextSecrets(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Sources: []Source{
|
||||
{Name: "a", APIKey: encPrefix + "abc", Headers: map[string]string{"H": encPrefix + "x"}},
|
||||
{Name: "b", APIKey: "sk-in-clear", Headers: map[string]string{"H2": "clear-too"}},
|
||||
},
|
||||
Keys: []GWKey{{Key: "sk-gw-clear"}},
|
||||
}
|
||||
if got := cfg.countPlaintextSecrets(); got != 3 {
|
||||
t.Errorf("countPlaintextSecrets = %d, want 3", got)
|
||||
}
|
||||
if !cfg.hasPlaintextSecrets() {
|
||||
t.Error("hasPlaintextSecrets = false, want true")
|
||||
}
|
||||
cfg.Sources[1].APIKey = encPrefix + "y"
|
||||
cfg.Sources[1].Headers["H2"] = encPrefix + "z"
|
||||
cfg.Keys[0].Key = encPrefix + "k"
|
||||
if cfg.hasPlaintextSecrets() {
|
||||
t.Error("hasPlaintextSecrets = true after sealing everything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSecretsFailsLoudlyOnWrongMasterKey is the negative case that
|
||||
// guards the migration: with a wrong key, a sealed value must NOT be handed
|
||||
// back as ciphertext for a later re-seal (that compounds corruption and turns
|
||||
// one lost key file into permanently unusable config).
|
||||
func TestNormalizeSecretsFailsLoudlyOnWrongMasterKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
runtime := filepath.Join(dir, "runtime.json")
|
||||
good := writeMasterKey(t, runtime)
|
||||
|
||||
cfg := &Config{
|
||||
Path: path,
|
||||
Listen: "127.0.0.1:0",
|
||||
Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-secret", Adapter: "openai", Models: []Model{{ID: "m"}}}},
|
||||
}
|
||||
cfg.AttachSecretBox(good)
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealedOnce, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A different master key must be rejected, loudly. NewSecretBox derives the
|
||||
// key path from the RUNTIME file's directory, so the wrong key has to live
|
||||
// in a different directory — otherwise it would read the good master.key.
|
||||
otherDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(otherDir, "master.key"),
|
||||
[]byte(strings.Repeat("cd", 32)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badBox, err := NewSecretBox(filepath.Join(otherDir, "runtime.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded.AttachSecretBox(badBox)
|
||||
if err := loaded.NormalizeSecretsForRun(badBox); err == nil {
|
||||
t.Fatal("expected an error with the wrong master key, got nil")
|
||||
}
|
||||
|
||||
// Crucially: the file must be untouched — no second seal on top.
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != string(sealedOnce) {
|
||||
t.Error("config file was rewritten despite the decrypt failure")
|
||||
}
|
||||
if n := strings.Count(string(after), encPrefix); n != strings.Count(string(sealedOnce), encPrefix) {
|
||||
t.Errorf("sealed count changed %d -> %d (double encryption)", strings.Count(string(sealedOnce), encPrefix), n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user