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        // Env-injected keys (`api_key_from_env`) are runtime-only: leave
143        // `api_key_encrypted` untouched so they're never baked into config.json
144        // on save (which would otherwise persist the secret even after the env
145        // var is removed). (#253)
146        if let Some(openai) = self.providers.openai.as_mut() {
147            if !openai.api_key_from_env {
148                let api_key = openai.api_key.trim();
149                // Only (re)encrypt when we actually hold a plaintext key. When the
150                // plaintext is empty because the stored ciphertext failed to
151                // decrypt at hydration (config.json moved across machines, a
152                // machine-id change, the ephemeral fallback key), DON'T null the
153                // ciphertext — that would permanently drop a working key the user
154                // never touched on the next unrelated save. #268.
155                if !api_key.is_empty() {
156                    openai.api_key_encrypted = Some(
157                        crate::encryption::encrypt(api_key)
158                            .context("Failed to encrypt OpenAI api_key")?,
159                    );
160                }
161            }
162        }
163
164        if let Some(anthropic) = self.providers.anthropic.as_mut() {
165            if !anthropic.api_key_from_env {
166                let api_key = anthropic.api_key.trim();
167                // Empty plaintext → preserve existing ciphertext (see OpenAI above). #268.
168                if !api_key.is_empty() {
169                    anthropic.api_key_encrypted = Some(
170                        crate::encryption::encrypt(api_key)
171                            .context("Failed to encrypt Anthropic api_key")?,
172                    );
173                }
174            }
175        }
176
177        if let Some(gemini) = self.providers.gemini.as_mut() {
178            if !gemini.api_key_from_env {
179                let api_key = gemini.api_key.trim();
180                // Empty plaintext → preserve existing ciphertext (see OpenAI above). #268.
181                if !api_key.is_empty() {
182                    gemini.api_key_encrypted = Some(
183                        crate::encryption::encrypt(api_key)
184                            .context("Failed to encrypt Gemini api_key")?,
185                    );
186                }
187            }
188        }
189
190        if let Some(bodhi) = self.providers.bodhi.as_mut() {
191            let api_key = bodhi.api_key.trim();
192            // Empty plaintext → preserve existing ciphertext (see OpenAI above). #268.
193            if !api_key.is_empty() {
194                bodhi.api_key_encrypted = Some(
195                    crate::encryption::encrypt(api_key)
196                        .context("Failed to encrypt Bodhi api_key")?,
197                );
198            }
199        }
200
201        Ok(())
202    }
203
204    // ── Provider instance API keys ─────────────────────────────────────
205
206    /// Hydrate plaintext `api_key` fields on provider instances from their
207    /// encrypted counterparts.
208    pub fn hydrate_provider_instance_api_keys_from_encrypted(&mut self) {
209        for (id, instance) in self.provider_instances.iter_mut() {
210            if instance.api_key.trim().is_empty() {
211                if let Some(encrypted) = instance.api_key_encrypted.as_deref() {
212                    match crate::encryption::decrypt(encrypted) {
213                        Ok(value) => instance.api_key = value,
214                        Err(e) => {
215                            tracing::warn!(instance_id = id, "Failed to decrypt api_key: {}", e)
216                        }
217                    }
218                }
219            }
220        }
221    }
222
223    /// Re-encrypt all provider instance API keys and write back to
224    /// `api_key_encrypted`. Used before persisting to disk.
225    pub fn refresh_provider_instance_api_keys_encrypted(&mut self) -> Result<()> {
226        for (id, instance) in self.provider_instances.iter_mut() {
227            let api_key = instance.api_key.trim();
228            // Empty plaintext → preserve existing ciphertext (see
229            // refresh_provider_api_keys_encrypted). #268.
230            if !api_key.is_empty() {
231                instance.api_key_encrypted = Some(crate::encryption::encrypt(api_key).context(
232                    format!("Failed to encrypt api_key for provider instance '{}'", id),
233                )?);
234            }
235        }
236        Ok(())
237    }
238
239    // ── MCP secrets ────────────────────────────────────────────────────
240
241    pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
242        for server in self.mcp.servers.iter_mut() {
243            match &mut server.transport {
244                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
245                    if stdio.env_encrypted.is_empty() {
246                        continue;
247                    }
248
249                    // Avoid borrow-checker gymnastics by iterating a cloned map.
250                    for (key, encrypted) in stdio.env_encrypted.clone() {
251                        let should_hydrate = stdio
252                            .env
253                            .get(&key)
254                            .map(|v| v.trim().is_empty())
255                            .unwrap_or(true);
256                        if !should_hydrate {
257                            continue;
258                        }
259
260                        match crate::encryption::decrypt(&encrypted) {
261                            Ok(value) => {
262                                stdio.env.insert(key, value);
263                            }
264                            Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
265                        }
266                    }
267                }
268                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
269                    for header in sse.headers.iter_mut() {
270                        if !header.value.trim().is_empty() {
271                            continue;
272                        }
273                        let Some(encrypted) = header.value_encrypted.as_deref() else {
274                            continue;
275                        };
276                        match crate::encryption::decrypt(encrypted) {
277                            Ok(value) => header.value = value,
278                            Err(e) => {
279                                tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
280                            }
281                        }
282                    }
283                }
284                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
285                    for header in sh.headers.iter_mut() {
286                        if !header.value.trim().is_empty() {
287                            continue;
288                        }
289                        let Some(encrypted) = header.value_encrypted.as_deref() else {
290                            continue;
291                        };
292                        match crate::encryption::decrypt(encrypted) {
293                            Ok(value) => header.value = value,
294                            Err(e) => {
295                                tracing::warn!(
296                                    "Failed to decrypt MCP StreamableHTTP header value: {}",
297                                    e
298                                )
299                            }
300                        }
301                    }
302                }
303            }
304        }
305    }
306
307    pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
308        for server in self.mcp.servers.iter_mut() {
309            match &mut server.transport {
310                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
311                    stdio.env_encrypted.clear();
312                    for (key, value) in &stdio.env {
313                        let encrypted = crate::encryption::encrypt(value).with_context(|| {
314                            format!("Failed to encrypt MCP stdio env var '{key}'")
315                        })?;
316                        stdio.env_encrypted.insert(key.clone(), encrypted);
317                    }
318                }
319                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
320                    for header in sse.headers.iter_mut() {
321                        let configured = !header.value.trim().is_empty();
322                        header.value_encrypted = if !configured {
323                            None
324                        } else {
325                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
326                                format!("Failed to encrypt MCP SSE header '{}'", header.name)
327                            })?)
328                        };
329                    }
330                }
331                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
332                    for header in sh.headers.iter_mut() {
333                        let configured = !header.value.trim().is_empty();
334                        header.value_encrypted = if !configured {
335                            None
336                        } else {
337                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
338                                format!(
339                                    "Failed to encrypt MCP StreamableHTTP header '{}'",
340                                    header.name
341                                )
342                            })?)
343                        };
344                    }
345                }
346            }
347        }
348
349        Ok(())
350    }
351
352    // ── Env vars encryption ────────────────────────────────────────────
353
354    /// Decrypt secret env vars into in-memory plaintext after loading config.
355    pub fn hydrate_env_vars_from_encrypted(&mut self) {
356        for entry in &mut self.env_vars {
357            if !entry.secret {
358                continue;
359            }
360            if !entry.value.trim().is_empty() {
361                // Already has plaintext (e.g. in-memory update).
362                continue;
363            }
364            let Some(encrypted) = &entry.value_encrypted else {
365                continue;
366            };
367            match crate::encryption::decrypt(encrypted) {
368                Ok(value) => entry.value = value,
369                Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
370            }
371        }
372    }
373
374    /// Re-encrypt secret env vars before persisting to disk.
375    pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
376        for entry in &mut self.env_vars {
377            if entry.secret && !entry.value.trim().is_empty() {
378                entry.value_encrypted = Some(
379                    crate::encryption::encrypt(&entry.value)
380                        .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
381                );
382            } else if !entry.secret {
383                entry.value_encrypted = None;
384            }
385        }
386        Ok(())
387    }
388
389    /// Clear plaintext values for secrets before serialization to disk.
390    pub fn sanitize_env_vars_for_disk(&mut self) {
391        for entry in &mut self.env_vars {
392            if entry.secret {
393                entry.value = String::new();
394            }
395        }
396    }
397
398    // ── Broker client token encryption ─────────────────────────────────
399
400    /// Decrypt the broker token into in-memory plaintext after loading config.
401    pub fn hydrate_broker_token_from_encrypted(&mut self) {
402        let Some(broker) = self.subagents.broker.as_mut() else {
403            return;
404        };
405        if !broker.token.trim().is_empty() {
406            return; // already has plaintext
407        }
408        if let Some(encrypted) = &broker.token_encrypted {
409            match crate::encryption::decrypt(encrypted) {
410                Ok(value) => broker.token = value,
411                Err(e) => tracing::warn!("Failed to decrypt broker token: {}", e),
412            }
413        }
414    }
415
416    /// Re-encrypt the broker token before persisting to disk.
417    pub fn refresh_broker_token_encrypted(&mut self) -> Result<()> {
418        let Some(broker) = self.subagents.broker.as_mut() else {
419            return Ok(());
420        };
421        if broker.token.trim().is_empty() {
422            // Keep any existing ciphertext (a redacted round-trip never re-sends it).
423            return Ok(());
424        }
425        broker.token_encrypted = Some(
426            crate::encryption::encrypt(&broker.token).context("Failed to encrypt broker token")?,
427        );
428        Ok(())
429    }
430
431    /// Clear the plaintext broker token before serialization to disk.
432    pub fn sanitize_broker_token_for_disk(&mut self) {
433        if let Some(broker) = self.subagents.broker.as_mut() {
434            broker.token = String::new();
435        }
436    }
437
438    // ── Notification channel secrets (ntfy token, Bark device key) ─────
439
440    /// Decrypt notification-channel secrets into in-memory plaintext after
441    /// loading config. Mirrors [`Config::hydrate_provider_api_keys_from_encrypted`]:
442    /// the plaintext fields are `#[serde(skip_serializing)]` (never on disk), so
443    /// this is the only way they get populated after a fresh load.
444    pub fn hydrate_notifications_from_encrypted(&mut self) {
445        let ntfy = &mut self.notifications.ntfy;
446        if ntfy
447            .token
448            .as_deref()
449            .map(str::trim)
450            .unwrap_or("")
451            .is_empty()
452        {
453            if let Some(encrypted) = ntfy.token_encrypted.as_deref() {
454                match crate::encryption::decrypt(encrypted) {
455                    Ok(value) => ntfy.token = Some(value),
456                    Err(e) => tracing::warn!("Failed to decrypt ntfy token: {}", e),
457                }
458            }
459        }
460
461        let bark = &mut self.notifications.bark;
462        if bark
463            .device_key
464            .as_deref()
465            .map(str::trim)
466            .unwrap_or("")
467            .is_empty()
468        {
469            if let Some(encrypted) = bark.device_key_encrypted.as_deref() {
470                match crate::encryption::decrypt(encrypted) {
471                    Ok(value) => bark.device_key = Some(value),
472                    Err(e) => tracing::warn!("Failed to decrypt Bark device key: {}", e),
473                }
474            }
475        }
476    }
477
478    /// Re-encrypt notification-channel secrets from current in-memory plaintext
479    /// before persisting to disk. Mirrors
480    /// [`Config::refresh_provider_api_keys_encrypted`]: an empty/absent
481    /// plaintext leaves any existing ciphertext intact (a redacted round-trip
482    /// where the client never re-sent the secret keeps it).
483    pub fn refresh_notifications_encrypted(&mut self) -> Result<()> {
484        let ntfy = &mut self.notifications.ntfy;
485        let token = ntfy.token.as_deref().unwrap_or("").trim();
486        if !token.is_empty() {
487            ntfy.token_encrypted =
488                Some(crate::encryption::encrypt(token).context("Failed to encrypt ntfy token")?);
489        }
490
491        let bark = &mut self.notifications.bark;
492        let device_key = bark.device_key.as_deref().unwrap_or("").trim();
493        if !device_key.is_empty() {
494            bark.device_key_encrypted = Some(
495                crate::encryption::encrypt(device_key)
496                    .context("Failed to encrypt Bark device key")?,
497            );
498        }
499
500        Ok(())
501    }
502
503    // ── bamboo-connect platform tokens (Telegram bot token, etc.) ───────
504
505    /// Decrypt every configured platform's token (and Feishu `app_secret`)
506    /// into in-memory plaintext after loading config. Mirrors
507    /// [`Config::hydrate_notifications_from_encrypted`]: both fields are
508    /// `#[serde(skip_serializing)]` (never on disk), so this is the only way
509    /// they get populated after a fresh load.
510    pub fn hydrate_connect_platform_tokens_from_encrypted(&mut self) {
511        for platform in &mut self.connect.platforms {
512            let has_plaintext = platform
513                .token
514                .as_deref()
515                .map(str::trim)
516                .map(|value| !value.is_empty())
517                .unwrap_or(false);
518            if !has_plaintext {
519                if let Some(encrypted) = platform.token_encrypted.as_deref() {
520                    match crate::encryption::decrypt(encrypted) {
521                        Ok(value) => platform.token = Some(value),
522                        Err(e) => tracing::warn!(
523                            "Failed to decrypt connect platform '{}' token: {}",
524                            platform.platform_type,
525                            e
526                        ),
527                    }
528                }
529            }
530
531            let has_app_secret_plaintext = platform
532                .app_secret
533                .as_deref()
534                .map(str::trim)
535                .map(|value| !value.is_empty())
536                .unwrap_or(false);
537            if !has_app_secret_plaintext {
538                if let Some(encrypted) = platform.app_secret_encrypted.as_deref() {
539                    match crate::encryption::decrypt(encrypted) {
540                        Ok(value) => platform.app_secret = Some(value),
541                        Err(e) => tracing::warn!(
542                            "Failed to decrypt connect platform '{}' app_secret: {}",
543                            platform.platform_type,
544                            e
545                        ),
546                    }
547                }
548            }
549        }
550    }
551
552    /// Re-encrypt every configured platform's token (and Feishu `app_secret`)
553    /// from current in-memory plaintext before persisting to disk. Mirrors
554    /// [`Config::refresh_notifications_encrypted`]: an empty/absent plaintext
555    /// leaves any existing ciphertext intact (a redacted round-trip where the
556    /// client never re-sent the secret keeps it).
557    pub fn refresh_connect_platform_tokens_encrypted(&mut self) -> Result<()> {
558        for platform in &mut self.connect.platforms {
559            let token = platform.token.as_deref().unwrap_or("").trim();
560            if !token.is_empty() {
561                platform.token_encrypted =
562                    Some(crate::encryption::encrypt(token).with_context(|| {
563                        format!(
564                            "Failed to encrypt connect platform '{}' token",
565                            platform.platform_type
566                        )
567                    })?);
568            }
569
570            let app_secret = platform.app_secret.as_deref().unwrap_or("").trim();
571            if !app_secret.is_empty() {
572                platform.app_secret_encrypted =
573                    Some(crate::encryption::encrypt(app_secret).with_context(|| {
574                        format!(
575                            "Failed to encrypt connect platform '{}' app_secret",
576                            platform.platform_type
577                        )
578                    })?);
579            }
580        }
581        Ok(())
582    }
583
584    /// Restore env-sourced provider `api_key`s that a serde round-trip dropped.
585    ///
586    /// `api_key` is `#[serde(skip_serializing)]`, so serializing `previous` and
587    /// deserializing it back — as the settings-PATCH merge in
588    /// `config_manager::build_merged_config` does — loses every provider's
589    /// plaintext key. `hydrate_provider_api_keys_from_encrypted` then restores
590    /// only keys that have a persisted ciphertext, which an env-injected key
591    /// never has (that's the #253 design). Without this, a PATCH to ANY provider
592    /// setting silently blanks the live env-sourced key of every OTHER provider
593    /// until the process restarts.
594    ///
595    /// Copies the key back from `previous` for each provider still flagged
596    /// env-sourced there whose key wasn't explicitly re-set by the patch (i.e. is
597    /// empty after the round-trip), so an explicit `api_key` in the patch still
598    /// wins. #373.
599    pub fn preserve_env_sourced_provider_keys(&mut self, previous: &Config) {
600        macro_rules! restore_env_key {
601            ($field:ident) => {
602                if let (Some(current), Some(prev)) = (
603                    self.providers.$field.as_mut(),
604                    previous.providers.$field.as_ref(),
605                ) {
606                    if prev.api_key_from_env && current.api_key.trim().is_empty() {
607                        current.api_key = prev.api_key.clone();
608                        current.api_key_from_env = true;
609                    }
610                }
611            };
612        }
613        restore_env_key!(openai);
614        restore_env_key!(anthropic);
615        restore_env_key!(gemini);
616    }
617}