fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte

This commit is contained in:
sky5454 2026-03-15 05:52:55 +08:00
parent 23c0ca1c77
commit 4fdff3e5c9

View file

@ -1,7 +1,6 @@
package config package config
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@ -864,30 +863,32 @@ func LoadConfig(path string) (*Config, error) {
return cfg, nil return cfg, nil
} }
// encryptPlaintextAPIKeys rewrites plaintext api_key values in raw JSON using // encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values
// the entries already parsed into models. Returns the rewritten bytes when at // encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or
// least one key was sealed, or nil when nothing changed. // empty). Returns (nil, error) if any key fails to encrypt — callers must treat
func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string, raw []byte) []byte { // this as a hard failure to prevent a mixed plaintext/ciphertext state on disk.
result := raw // Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig
// and leave JSON marshaling to the caller.
func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) {
sealed := make([]ModelConfig, len(models))
copy(sealed, models)
changed := false changed := false
for _, m := range models { for i := range sealed {
m := &sealed[i]
if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") { if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") {
continue continue
} }
encrypted, err := credential.Encrypt(passphrase, "", m.APIKey) encrypted, err := credential.Encrypt(passphrase, "", m.APIKey)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "picoclaw: warning: cannot seal api_key in config: %v\n", err) return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err)
continue
} }
oldJSON, _ := json.Marshal(m.APIKey) m.APIKey = encrypted
newJSON, _ := json.Marshal(encrypted)
result = bytes.Replace(result, oldJSON, newJSON, 1)
changed = true changed = true
} }
if !changed { if !changed {
return nil return nil, nil
} }
return result return sealed, nil
} }
// resolveAPIKeys decrypts or dereferences each api_key in models in-place. // resolveAPIKeys decrypts or dereferences each api_key in models in-place.
@ -918,17 +919,22 @@ func (c *Config) migrateChannelConfigs() {
} }
func SaveConfig(path string, cfg *Config) error { func SaveConfig(path string, cfg *Config) error {
if passphrase := credential.PassphraseProvider(); passphrase != "" {
sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase)
if err != nil {
return err
}
if sealed != nil {
tmp := *cfg
tmp.ModelList = sealed
cfg = &tmp
}
}
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
return err return err
} }
if passphrase := credential.PassphraseProvider(); passphrase != "" {
if sealed := encryptPlaintextAPIKeys(cfg.ModelList, passphrase, data); sealed != nil {
data = sealed
}
}
return fileutil.WriteFileAtomic(path, data, 0o600) return fileutil.WriteFileAtomic(path, data, 0o600)
} }