mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-21 17:38:00 +00:00
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:
132
internal/config/secret.go
Normal file
132
internal/config/secret.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user