Skip to main content

bamboo_config/
config_crypto.rs

1//! Encryption, decryption, and hydration methods for [`Config`].
2//!
3//! These methods handle the in-memory hydration of encrypted credentials
4//! (API keys, proxy auth, MCP secrets, env vars) and their re-encryption
5//! before persisting to disk.
6
7use anyhow::{Context, Result};
8
9use super::{Config, ProxyAuth};
10
11impl Config {
12    // ── Proxy auth ─────────────────────────────────────────────────────
13
14    /// Populate `proxy_auth` (plaintext) from `proxy_auth_encrypted` if present.
15    ///
16    /// Many parts of the code rely on `proxy_auth` being hydrated in-memory so
17    /// we can re-encrypt deterministically on save without ever persisting
18    /// plaintext credentials.
19    pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
20        if self.proxy_auth.is_some() {
21            return;
22        }
23
24        // Backward compatibility:
25        // Older Bodhi/Tauri builds persisted proxy auth as per-scheme encrypted fields:
26        // `http_proxy_auth_encrypted` / `https_proxy_auth_encrypted`.
27        //
28        // Those live under `extra` (flatten) in the unified config. Seed the new
29        // `proxy_auth_encrypted` field so the rest of the code can stay uniform.
30        if self
31            .proxy_auth_encrypted
32            .as_deref()
33            .map(|s| s.trim().is_empty())
34            .unwrap_or(true)
35        {
36            let legacy = self
37                .extra
38                .get("https_proxy_auth_encrypted")
39                .and_then(|v| v.as_str())
40                .or_else(|| {
41                    self.extra
42                        .get("http_proxy_auth_encrypted")
43                        .and_then(|v| v.as_str())
44                })
45                .map(|s| s.trim())
46                .filter(|s| !s.is_empty())
47                .map(|s| s.to_string());
48
49            if let Some(legacy) = legacy {
50                self.proxy_auth_encrypted = Some(legacy);
51            }
52        }
53
54        let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
55            return;
56        };
57
58        match crate::encryption::decrypt(encrypted) {
59            Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
60                Ok(auth) => {
61                    self.proxy_auth = Some(auth);
62                    // Once hydrated successfully, drop legacy keys so a future save writes only
63                    // the canonical `proxy_auth_encrypted` field.
64                    self.extra.remove("http_proxy_auth_encrypted");
65                    self.extra.remove("https_proxy_auth_encrypted");
66                }
67                Err(e) => tracing::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
68            },
69            Err(e) => tracing::warn!("Failed to decrypt proxy auth: {}", e),
70        }
71    }
72
73    /// Refresh `proxy_auth_encrypted` from the current in-memory `proxy_auth`.
74    ///
75    /// This is used both when persisting the config to disk and when generating
76    /// API responses that should never include plaintext proxy credentials.
77    pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
78        // Keep on-disk representation fully derived from the in-memory plaintext:
79        // - Some(auth)  => always (re-)encrypt and store `proxy_auth_encrypted`
80        // - None        => remove `proxy_auth_encrypted`
81        let Some(auth) = self.proxy_auth.as_ref() else {
82            self.proxy_auth_encrypted = None;
83            return Ok(());
84        };
85
86        let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
87        let encrypted =
88            crate::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
89        self.proxy_auth_encrypted = Some(encrypted);
90        Ok(())
91    }
92
93    // ── Provider API keys ──────────────────────────────────────────────
94
95    pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
96        if let Some(openai) = self.providers.openai.as_mut() {
97            if openai.api_key.trim().is_empty() {
98                if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
99                    match crate::encryption::decrypt(encrypted) {
100                        Ok(value) => openai.api_key = value,
101                        Err(e) => tracing::warn!("Failed to decrypt OpenAI api_key: {}", e),
102                    }
103                }
104            }
105        }
106
107        if let Some(anthropic) = self.providers.anthropic.as_mut() {
108            if anthropic.api_key.trim().is_empty() {
109                if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
110                    match crate::encryption::decrypt(encrypted) {
111                        Ok(value) => anthropic.api_key = value,
112                        Err(e) => tracing::warn!("Failed to decrypt Anthropic api_key: {}", e),
113                    }
114                }
115            }
116        }
117
118        if let Some(gemini) = self.providers.gemini.as_mut() {
119            if gemini.api_key.trim().is_empty() {
120                if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
121                    match crate::encryption::decrypt(encrypted) {
122                        Ok(value) => gemini.api_key = value,
123                        Err(e) => tracing::warn!("Failed to decrypt Gemini api_key: {}", e),
124                    }
125                }
126            }
127        }
128
129        if let Some(bodhi) = self.providers.bodhi.as_mut() {
130            if bodhi.api_key.trim().is_empty() {
131                if let Some(encrypted) = bodhi.api_key_encrypted.as_deref() {
132                    match crate::encryption::decrypt(encrypted) {
133                        Ok(value) => bodhi.api_key = value,
134                        Err(e) => tracing::warn!("Failed to decrypt Bodhi api_key: {}", e),
135                    }
136                }
137            }
138        }
139    }
140
141    pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
142        if let Some(openai) = self.providers.openai.as_mut() {
143            let api_key = openai.api_key.trim();
144            openai.api_key_encrypted = if api_key.is_empty() {
145                None
146            } else {
147                Some(
148                    crate::encryption::encrypt(api_key)
149                        .context("Failed to encrypt OpenAI api_key")?,
150                )
151            };
152        }
153
154        if let Some(anthropic) = self.providers.anthropic.as_mut() {
155            let api_key = anthropic.api_key.trim();
156            anthropic.api_key_encrypted = if api_key.is_empty() {
157                None
158            } else {
159                Some(
160                    crate::encryption::encrypt(api_key)
161                        .context("Failed to encrypt Anthropic api_key")?,
162                )
163            };
164        }
165
166        if let Some(gemini) = self.providers.gemini.as_mut() {
167            let api_key = gemini.api_key.trim();
168            gemini.api_key_encrypted = if api_key.is_empty() {
169                None
170            } else {
171                Some(
172                    crate::encryption::encrypt(api_key)
173                        .context("Failed to encrypt Gemini api_key")?,
174                )
175            };
176        }
177
178        if let Some(bodhi) = self.providers.bodhi.as_mut() {
179            let api_key = bodhi.api_key.trim();
180            bodhi.api_key_encrypted = if api_key.is_empty() {
181                None
182            } else {
183                Some(
184                    crate::encryption::encrypt(api_key)
185                        .context("Failed to encrypt Bodhi api_key")?,
186                )
187            };
188        }
189
190        Ok(())
191    }
192
193    // ── Provider instance API keys ─────────────────────────────────────
194
195    /// Hydrate plaintext `api_key` fields on provider instances from their
196    /// encrypted counterparts.
197    pub fn hydrate_provider_instance_api_keys_from_encrypted(&mut self) {
198        for (id, instance) in self.provider_instances.iter_mut() {
199            if instance.api_key.trim().is_empty() {
200                if let Some(encrypted) = instance.api_key_encrypted.as_deref() {
201                    match crate::encryption::decrypt(encrypted) {
202                        Ok(value) => instance.api_key = value,
203                        Err(e) => {
204                            tracing::warn!(instance_id = id, "Failed to decrypt api_key: {}", e)
205                        }
206                    }
207                }
208            }
209        }
210    }
211
212    /// Re-encrypt all provider instance API keys and write back to
213    /// `api_key_encrypted`. Used before persisting to disk.
214    pub fn refresh_provider_instance_api_keys_encrypted(&mut self) -> Result<()> {
215        for (id, instance) in self.provider_instances.iter_mut() {
216            let api_key = instance.api_key.trim();
217            instance.api_key_encrypted = if api_key.is_empty() {
218                None
219            } else {
220                Some(crate::encryption::encrypt(api_key).context(format!(
221                    "Failed to encrypt api_key for provider instance '{}'",
222                    id
223                ))?)
224            };
225        }
226        Ok(())
227    }
228
229    // ── MCP secrets ────────────────────────────────────────────────────
230
231    pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
232        for server in self.mcp.servers.iter_mut() {
233            match &mut server.transport {
234                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
235                    if stdio.env_encrypted.is_empty() {
236                        continue;
237                    }
238
239                    // Avoid borrow-checker gymnastics by iterating a cloned map.
240                    for (key, encrypted) in stdio.env_encrypted.clone() {
241                        let should_hydrate = stdio
242                            .env
243                            .get(&key)
244                            .map(|v| v.trim().is_empty())
245                            .unwrap_or(true);
246                        if !should_hydrate {
247                            continue;
248                        }
249
250                        match crate::encryption::decrypt(&encrypted) {
251                            Ok(value) => {
252                                stdio.env.insert(key, value);
253                            }
254                            Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
255                        }
256                    }
257                }
258                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
259                    for header in sse.headers.iter_mut() {
260                        if !header.value.trim().is_empty() {
261                            continue;
262                        }
263                        let Some(encrypted) = header.value_encrypted.as_deref() else {
264                            continue;
265                        };
266                        match crate::encryption::decrypt(encrypted) {
267                            Ok(value) => header.value = value,
268                            Err(e) => {
269                                tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
270                            }
271                        }
272                    }
273                }
274                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
275                    for header in sh.headers.iter_mut() {
276                        if !header.value.trim().is_empty() {
277                            continue;
278                        }
279                        let Some(encrypted) = header.value_encrypted.as_deref() else {
280                            continue;
281                        };
282                        match crate::encryption::decrypt(encrypted) {
283                            Ok(value) => header.value = value,
284                            Err(e) => {
285                                tracing::warn!(
286                                    "Failed to decrypt MCP StreamableHTTP header value: {}",
287                                    e
288                                )
289                            }
290                        }
291                    }
292                }
293            }
294        }
295    }
296
297    pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
298        for server in self.mcp.servers.iter_mut() {
299            match &mut server.transport {
300                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
301                    stdio.env_encrypted.clear();
302                    for (key, value) in &stdio.env {
303                        let encrypted = crate::encryption::encrypt(value).with_context(|| {
304                            format!("Failed to encrypt MCP stdio env var '{key}'")
305                        })?;
306                        stdio.env_encrypted.insert(key.clone(), encrypted);
307                    }
308                }
309                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
310                    for header in sse.headers.iter_mut() {
311                        let configured = !header.value.trim().is_empty();
312                        header.value_encrypted = if !configured {
313                            None
314                        } else {
315                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
316                                format!("Failed to encrypt MCP SSE header '{}'", header.name)
317                            })?)
318                        };
319                    }
320                }
321                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
322                    for header in sh.headers.iter_mut() {
323                        let configured = !header.value.trim().is_empty();
324                        header.value_encrypted = if !configured {
325                            None
326                        } else {
327                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
328                                format!(
329                                    "Failed to encrypt MCP StreamableHTTP header '{}'",
330                                    header.name
331                                )
332                            })?)
333                        };
334                    }
335                }
336            }
337        }
338
339        Ok(())
340    }
341
342    // ── Env vars encryption ────────────────────────────────────────────
343
344    /// Decrypt secret env vars into in-memory plaintext after loading config.
345    pub fn hydrate_env_vars_from_encrypted(&mut self) {
346        for entry in &mut self.env_vars {
347            if !entry.secret {
348                continue;
349            }
350            if !entry.value.trim().is_empty() {
351                // Already has plaintext (e.g. in-memory update).
352                continue;
353            }
354            let Some(encrypted) = &entry.value_encrypted else {
355                continue;
356            };
357            match crate::encryption::decrypt(encrypted) {
358                Ok(value) => entry.value = value,
359                Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
360            }
361        }
362    }
363
364    /// Re-encrypt secret env vars before persisting to disk.
365    pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
366        for entry in &mut self.env_vars {
367            if entry.secret && !entry.value.trim().is_empty() {
368                entry.value_encrypted = Some(
369                    crate::encryption::encrypt(&entry.value)
370                        .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
371                );
372            } else if !entry.secret {
373                entry.value_encrypted = None;
374            }
375        }
376        Ok(())
377    }
378
379    /// Clear plaintext values for secrets before serialization to disk.
380    pub fn sanitize_env_vars_for_disk(&mut self) {
381        for entry in &mut self.env_vars {
382            if entry.secret {
383                entry.value = String::new();
384            }
385        }
386    }
387}