feat(secrets): encrypt sensitive store fields (AES-GCM, master.key 0600, api_key_env) + fast models-endpoint probing (fix zen backlog + source status) + UI cleanup (drop redundant parens labels, grid models 4/row)

This commit is contained in:
root
2026-08-10 10:33:03 +08:00
parent e0978db150
commit 839f33ba01
8 changed files with 405 additions and 56 deletions

View File

@ -36,6 +36,7 @@ type Source struct {
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
Adapter string `yaml:"adapter" json:"adapter"`
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override

View File

@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@ -95,7 +96,7 @@ func TestStoreUpsertRemove(t *testing.T) {
if err := s.Load(); err != nil {
t.Fatal(err)
}
if err := s.Upsert(Source{Name: "a", BaseURL: "http://a", Models: []Model{{ID: "m"}}}); err != nil {
if err := s.Upsert(Source{Name: "a", BaseURL: "http://a", APIKey: "sk-a", Models: []Model{{ID: "m"}}}); err != nil {
t.Fatal(err)
}
if err := s.Upsert(Source{Name: "b", BaseURL: "http://b", Models: []Model{{ID: "m2"}}}); err != nil {
@ -119,4 +120,80 @@ func TestStoreUpsertRemove(t *testing.T) {
if len(s2.List()) != 1 {
t.Fatalf("reloaded list = %d", len(s2.List()))
}
}
func TestStoreSecretEncryption(t *testing.T) {
t.Setenv("LLMS_PROXY_MASTER_KEY", "")
dir := t.TempDir()
path := filepath.Join(dir, "runtime.json")
s := NewStore(path)
if s.box == nil {
t.Fatal("expected secret box")
}
if err := s.Load(); err != nil {
t.Fatal(err)
}
headers := map[string]string{"Authorization": "Bearer sk-hdr", "X-Custom": "plain"}
if err := s.Upsert(Source{Name: "a", BaseURL: "http://a", APIKey: "sk-secret-123", Headers: headers}); err != nil {
t.Fatal(err)
}
if err := s.SaveKey(GWKey{Key: "gw-secret", Role: "admin"}); err != nil {
t.Fatal(err)
}
// file on disk must not contain plaintext secrets
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, plain := range []string{"sk-secret-123", "Bearer sk-hdr", "gw-secret"} {
if strings.Contains(string(raw), plain) {
t.Fatalf("secret %q stored in plaintext on disk", plain)
}
}
// in-memory stays plaintext after the writes
src := s.List()[0]
if src.APIKey != "sk-secret-123" {
t.Fatalf("in-memory api_key = %q", src.APIKey)
}
if src.Headers["Authorization"] != "Bearer sk-hdr" {
t.Fatal("in-memory header not plaintext")
}
// reload: decrypted back
s2 := NewStore(path)
if err := s2.Load(); err != nil {
t.Fatal(err)
}
if s2.List()[0].APIKey != "sk-secret-123" {
t.Fatalf("reloaded api_key = %q", s2.List()[0].APIKey)
}
if k, ok := s2.KeyByValue("gw-secret"); !ok || k.Role != "admin" {
t.Fatalf("reloaded key lookup failed: %+v %v", k, ok)
}
}
func TestSecretBoxRoundTrip(t *testing.T) {
dir := t.TempDir()
box, err := NewSecretBox(filepath.Join(dir, "runtime.json"))
if err != nil {
t.Fatal(err)
}
v, err := box.Encrypt("sk-abc-xyz")
if err != nil {
t.Fatal(err)
}
if strings.HasPrefix(v, "sk-") || strings.Contains(v, "abc-xyz") {
t.Fatalf("ciphertext leaked plaintext: %q", v)
}
out, err := box.Decrypt(v)
if err != nil || out != "sk-abc-xyz" {
t.Fatalf("roundtrip: %q %v", out, err)
}
if plain, err := box.Decrypt("sk-plain"); err != nil || plain != "sk-plain" {
t.Fatalf("plain passthrough: %q %v", plain, err)
}
// wrong key must error
bad, _ := NewSecretBox(filepath.Join(t.TempDir(), "runtime.json"))
if _, err := bad.Decrypt(v); err == nil {
t.Fatal("expected decrypt failure with wrong key")
}
}

132
internal/config/secret.go Normal file
View File

@ -0,0 +1,132 @@
package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
var encPrefix = "enc:v1:"
// SecretBox encrypts and decrypts sensitive values (upstream API keys,
// custom header values, gateway keys) for persist-time protection. The
// master key comes from the LLMS_PROXY_MASTER_KEY environment variable
// (hex, 64 chars) or from a generated <runtime-dir>/master.key file.
type SecretBox struct {
aead cipher.AEAD
}
// NewSecretBox builds a box from the env var, falling back to a master.key
// file next to the runtime file. If neither exists, a fresh random key is
// generated and written to master.key with mode 0600.
func NewSecretBox(runtimeFile string) (*SecretBox, error) {
if v := os.Getenv("LLMS_PROXY_MASTER_KEY"); v != "" {
key, err := hex.DecodeString(strings.TrimSpace(v))
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("LLMS_PROXY_MASTER_KEY: expected 64 hex chars")
}
return newBox(key)
}
path := filepath.Join(filepath.Dir(runtimeFile), "master.key")
key, err := os.ReadFile(path)
if os.IsNotExist(err) {
key = make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := os.WriteFile(path, []byte(hex.EncodeToString(key)), 0600); err != nil {
return nil, fmt.Errorf("write %s: %w", path, err)
}
log.Printf("[config] generated master key at %s (mode 0600, keep it safe)", path)
} else if err != nil {
return nil, err
} else {
key, err = hex.DecodeString(strings.TrimSpace(string(key)))
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("master.key %s: expected 64 hex chars (regenerate if empty)", path)
}
}
return newBox(key)
}
func newBox(key []byte) (*SecretBox, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &SecretBox{aead: aead}, nil
}
// Encrypt returns "" for empty input, otherwise "enc:v1:<base64>".
func (b *SecretBox) Encrypt(plain string) (string, error) {
if plain == "" {
return "", nil
}
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 encPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
}
// Decrypt reverses Encrypt. Plain values (no prefix) are returned unchanged
// so old unencrypted files keep loading. Bad ciphertext is returned as-is
// with an error, never silently rewritten.
func (b *SecretBox) Decrypt(v string) (string, error) {
if !strings.HasPrefix(v, encPrefix) {
return v, nil
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(v, encPrefix))
if err != nil {
return v, err
}
n := b.aead.NonceSize()
if len(raw) < n {
return v, fmt.Errorf("ciphertext too short")
}
nonce, sealed := raw[:n], raw[n:]
plain, err := b.aead.Open(nil, nonce, sealed, nil)
if err != nil {
return v, fmt.Errorf("decrypt failed (master key changed?)")
}
return string(plain), nil
}
// MustDecrypt is a convenience wrapper that logs and returns the input on
// failure so a bad value never silently becomes empty.
func (b *SecretBox) MustDecrypt(v string) string {
out, err := b.Decrypt(v)
if err != nil {
head := v
if len(head) > 8 {
head = head[:8]
}
log.Printf("[config] secret decrypt failed for value %q…: %v", head, err)
return v
}
return out
}
// resolvedAPIKey resolves a source key from api_key_env (env var) or api_key.
func resolvedAPIKey(src Source, box *SecretBox) (string, error) {
if src.APIKeyEnv != "" {
v := os.Getenv(src.APIKeyEnv)
if v == "" {
return "", fmt.Errorf("source %s: env %s is empty or unset", src.Name, src.APIKeyEnv)
}
return v, nil
}
return box.MustDecrypt(src.APIKey), nil
}

View File

@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"log"
"os"
"sync"
)
@ -12,10 +13,25 @@ type Store struct {
mu sync.Mutex
path string
data RuntimeConfig
box *SecretBox
}
func NewStore(path string) *Store {
return &Store{path: path}
s := &Store{path: path}
if box, err := NewSecretBox(path); err == nil {
s.box = box
} else {
log.Printf("[config] secrets disabled: %v (sensitive values will be stored in plaintext)", err)
}
return s
}
// SecretBox exposes the box used for persist-time encryption of source
// api_key / header values read from other places (e.g. YAML). May be nil.
func (s *Store) SecretBox() *SecretBox {
s.mu.Lock()
defer s.mu.Unlock()
return s.box
}
// Load reads the runtime file (missing file = empty state).
@ -30,7 +46,27 @@ func (s *Store) Load() error {
}
return err
}
return json.Unmarshal(data, &s.data)
if err := json.Unmarshal(data, &s.data); err != nil {
return err
}
if s.box != nil {
s.decryptLocked()
}
return nil
}
// decryptLocked replaces persisted ciphertext fields with their plaintext in
// memory (the runtime always works with plaintext; only the file is sealed).
func (s *Store) decryptLocked() {
for i := range s.data.Sources {
s.data.Sources[i].APIKey = s.box.MustDecrypt(s.data.Sources[i].APIKey)
for k, v := range s.data.Sources[i].Headers {
s.data.Sources[i].Headers[k] = s.box.MustDecrypt(v)
}
}
for i := range s.data.Keys {
s.data.Keys[i].Key = s.box.MustDecrypt(s.data.Keys[i].Key)
}
}
// List returns the runtime sources (those edited via web UI).
@ -115,11 +151,44 @@ func (s *Store) DeletedAdapters() map[string]bool {
}
func (s *Store) persistLocked() error {
if s.box != nil {
s.encryptLocked()
}
b, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, b, 0644)
if s.box != nil {
s.decryptLocked()
}
if err := os.WriteFile(s.path, b, 0644); err != nil {
return err
}
return nil
}
// encryptLocked seals sensitive fields for the write; decryptLocked runs
// right after marshaling so in-memory data stays plaintext.
func (s *Store) encryptLocked() {
for i := range s.data.Sources {
if v, err := s.box.Encrypt(s.data.Sources[i].APIKey); err == nil {
s.data.Sources[i].APIKey = v
}
h := s.data.Sources[i].Headers
for k, v := range h {
if v == "" {
continue
}
if e, err := s.box.Encrypt(v); err == nil {
h[k] = e
}
}
}
for i := range s.data.Keys {
if v, err := s.box.Encrypt(s.data.Keys[i].Key); err == nil {
s.data.Keys[i].Key = v
}
}
}
func containsString(list []string, s string) bool {