mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
根因是 unseal 时序:NewFromConfig 把解密放在最后,而之前几步已经在读凭据。
1. seedKeys 重复播种(生产已累积 4 个同名 admin key)
seedKeys 用 cfg.Keys[i].Key 与明文 gateway_keys 比对去重,但此时内存里的
key 还是密文 enc:v1:…,比对永不命中 ⇒ 每次重启追加一个同值 admin key。
实测:core.New(path) 连续重启,seeded key 数 2→3→4 递增。
(旧测试用 NewFromConfig 构造全新内存对象,没有「盘上已有密文」这个前提,
复现不出 —— 必须走 core.New 这条读盘的生产路径。)
2. 启动恒重写 config.yaml
migratePlaintextSecrets 按内存状态判断,而 Save() 末尾会把内存恢复为明文,
于是每次调用都判定「还有明文」并重写;注释却自称幂等。
改为 UnsealSecrets 在解密前记录「盘上是否明文」,SealIfNeeded 据此决定
是否写回 ⇒ 已封存的配置启动不再落盘。
原测试 TestMigratePlaintextSecretsIsIdempotent 用 ModTime 比较,两次写落在同一
时间戳刻度内就看不出来,所以表现为 ~1/6 概率的 flake 而非稳定失败。已改为比较
文件内容并走真实启动路径(UnsealSecrets + SealIfNeeded),并顺带消除该 flake。
附带更正:先前判断「rebuildRegistry 也会拿到密文 API key」不成立 ——
mergedSources → resolveSourceKey 对每个 source 独立解密(belt-and-braces),
provider 始终拿到明文。unseal 前置仍予保留,以消除对该兜底路径的隐性依赖、
并让 seedKeys 在明文下比较。
判据:
- TestRestartDoesNotDuplicateSeededKeys(敏感:回退顺序必红)
- TestSealingIsIdempotentAcrossStarts(12/12 稳定,原先 1/6 flake)
- TestProvidersGetPlaintextCredentials(钉 provider 必须拿到明文这一不变量)
318 lines
10 KiB
Go
318 lines
10 KiB
Go
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"
|
|
"os"
|
|
"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 {
|
|
hadPlaintext, err := c.UnsealSecrets(box)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.SealIfNeeded(hadPlaintext)
|
|
}
|
|
|
|
// UnsealSecrets decrypts every sealed credential in memory and reports whether
|
|
// the config ON DISK still held plaintext (i.e. whether a sealing write is
|
|
// needed). It never writes; a failed decrypt (wrong master key) is returned so
|
|
// the process refuses to start instead of running with unusable credentials.
|
|
//
|
|
// The return value must be computed BEFORE unsealing and from the disk state,
|
|
// not from memory: after a Save the in-memory values are always plaintext, so a
|
|
// "is anything plaintext?" test run afterwards is unconditionally true and a
|
|
// caller would rewrite the file on every start. That was the actual behaviour -
|
|
// migratePlaintextSecrets() claimed to be idempotent in a comment but rewrote
|
|
// config.yaml on every boot.
|
|
//
|
|
// Callers that consume credentials (the provider registry, key seeding) must
|
|
// unseal FIRST. Seeding compares cfg.Keys[i].Key against the plaintext
|
|
// gateway_keys entries; running it while keys are still ciphertext made the
|
|
// dedupe never match, so every restart appended another copy of the same admin
|
|
// key (production accumulated four).
|
|
func (c *Config) UnsealSecrets(box *SecretBox) (bool, error) {
|
|
if box == nil {
|
|
return false, nil
|
|
}
|
|
c.AttachSecretBox(box)
|
|
hadPlaintext := c.hasPlaintextSecrets()
|
|
if err := c.normalizeSecrets(box); err != nil {
|
|
return hadPlaintext, err
|
|
}
|
|
return hadPlaintext, nil
|
|
}
|
|
|
|
// SealIfNeeded writes the config back once if hadPlaintext reported that the
|
|
// file still held clear-text credentials. When it is false the file is left
|
|
// untouched, which is what makes startup a no-op for an already-sealed config.
|
|
func (c *Config) SealIfNeeded(hadPlaintext bool) error {
|
|
if !hadPlaintext || c.box == nil || c.Path == "" {
|
|
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
|
|
}
|
|
|
|
// 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. Kept for callers that attach the box themselves; it decides from
|
|
// the in-memory state, which is why UnsealSecrets + SealIfNeeded (which decide
|
|
// from the on-disk state) are preferred on the startup path.
|
|
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
|
|
}
|
|
|
|
// PrintSecrets writes the config's credentials to stdout in the clear and
|
|
// returns an error only when the file cannot be read or the master key does not
|
|
// match. It is the operator counterpart to sealing-at-rest: with keys stored as
|
|
// ciphertext, "which key is this source using" must still be answerable.
|
|
//
|
|
// It deliberately takes a path rather than a *Config so the caller cannot
|
|
// accidentally hand it a config that has already been unsealed in memory, and it
|
|
// never writes anything.
|
|
func PrintSecrets(path string) error {
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
box, err := NewSecretBox(cfg.RuntimeFile)
|
|
if err != nil {
|
|
return fmt.Errorf("%w (is master.key present and intact?)", err)
|
|
}
|
|
if err := cfg.normalizeSecrets(box); err != nil {
|
|
return err
|
|
}
|
|
w := os.Stdout
|
|
fmt.Fprintf(w, "# %s — %d source(s), %d gateway key(s)\n", path, len(cfg.Sources), len(cfg.Keys))
|
|
for _, s := range cfg.Sources {
|
|
fmt.Fprintf(w, "source %-16s api_key=%s\n", s.Name, orNone(s.APIKey))
|
|
for k, v := range s.Headers {
|
|
if v != "" {
|
|
fmt.Fprintf(w, "source %-16s header[%s]=%s\n", s.Name, k, v)
|
|
}
|
|
}
|
|
}
|
|
for _, k := range cfg.Keys {
|
|
fmt.Fprintf(w, "key %-16s role=%-5s %s\n", k.Name, k.Role, k.Key)
|
|
}
|
|
fmt.Fprintf(w, "gateway_keys (legacy): %s\n", strings.Join(cfg.GatewayKeys, " "))
|
|
return nil
|
|
}
|
|
|
|
func orNone(s string) string {
|
|
if s == "" {
|
|
return "(unset)"
|
|
}
|
|
return s
|
|
}
|