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 { 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 } // 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 }