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};
8use serde::{Deserialize, Serialize};
9
10use super::{Config, ProxyAuth};
11use crate::patch::ProviderApiKeyIntents;
12
13trait CredentialResolver {
14    fn resolve(
15        &self,
16        credential_ref: &crate::CredentialRef,
17    ) -> crate::ConfigStoreResult<Option<crate::SecretValue>>;
18}
19
20impl CredentialResolver for crate::CredentialStore {
21    fn resolve(
22        &self,
23        credential_ref: &crate::CredentialRef,
24    ) -> crate::ConfigStoreResult<Option<crate::SecretValue>> {
25        crate::CredentialStore::resolve(self, credential_ref)
26    }
27}
28
29impl CredentialResolver for crate::credential_store::CredentialDocumentLkg {
30    fn resolve(
31        &self,
32        credential_ref: &crate::CredentialRef,
33    ) -> crate::ConfigStoreResult<Option<crate::SecretValue>> {
34        self.resolve(credential_ref)
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub(crate) struct AccessVerifierRecord {
40    pub hash: String,
41    pub salt: String,
42}
43
44pub fn access_password_credential_ref() -> crate::ConfigStoreResult<crate::CredentialRef> {
45    crate::credential_ref("access", "root", "password_verifier")
46}
47
48pub fn access_device_credential_ref(
49    device_id: &str,
50) -> crate::ConfigStoreResult<crate::CredentialRef> {
51    crate::credential_ref("access", device_id, "device_token_verifier")
52}
53
54pub(crate) fn encode_access_verifier(hash: &str, salt: &str) -> crate::ConfigStoreResult<String> {
55    validate_access_verifier(hash, salt)?;
56    serde_json::to_string(&AccessVerifierRecord {
57        hash: hash.to_string(),
58        salt: salt.to_string(),
59    })
60    .map_err(Into::into)
61}
62
63fn validate_access_verifier(hash: &str, salt: &str) -> crate::ConfigStoreResult<()> {
64    if hash.len() != 64
65        || !hash.bytes().all(|byte| byte.is_ascii_hexdigit())
66        || salt.is_empty()
67        || !salt.len().is_multiple_of(2)
68        || !salt.bytes().all(|byte| byte.is_ascii_hexdigit())
69    {
70        return Err(crate::ConfigStoreError::Validation(
71            "access-control verifier is invalid".to_string(),
72        ));
73    }
74    Ok(())
75}
76
77pub(crate) fn decode_access_verifier(
78    secret: &str,
79) -> crate::ConfigStoreResult<AccessVerifierRecord> {
80    let record: AccessVerifierRecord = serde_json::from_str(secret).map_err(|_| {
81        crate::ConfigStoreError::Validation(
82            "access-control verifier credential is invalid".to_string(),
83        )
84    })?;
85    validate_access_verifier(&record.hash, &record.salt)?;
86    Ok(record)
87}
88
89fn hydrate_header_credentials(
90    resolver: &impl CredentialResolver,
91    headers: &mut [bamboo_domain::mcp_config::HeaderConfig],
92) -> crate::ConfigStoreResult<()> {
93    for header in headers {
94        if !header.value.is_empty() {
95            continue;
96        }
97        let Some(raw_reference) = header.credential_ref.as_ref() else {
98            continue;
99        };
100        let reference = crate::CredentialRef::parse(raw_reference.clone())?;
101        header.value = resolver
102            .resolve(&reference)?
103            .ok_or_else(|| {
104                crate::ConfigStoreError::Validation(
105                    "referenced MCP credential is unavailable".to_string(),
106                )
107            })?
108            .expose()
109            .to_string();
110    }
111    Ok(())
112}
113
114impl crate::BrokerClientConfig {
115    /// Resolve an external broker bearer-token reference into runtime-only
116    /// plaintext. A configured reference that is missing or unreadable fails
117    /// closed so startup never silently dials the broker unauthenticated.
118    pub fn hydrate_credential_from_store(
119        &mut self,
120        data_dir: &std::path::Path,
121    ) -> crate::ConfigStoreResult<()> {
122        self.token_encrypted = None;
123        let Some(reference) = self.credential_ref.as_ref() else {
124            if self.configured {
125                return Err(crate::ConfigStoreError::Validation(
126                    "configured broker credential reference is missing".to_string(),
127                ));
128            }
129            self.token.clear();
130            return Ok(());
131        };
132        match crate::CredentialStore::open(data_dir).resolve(reference)? {
133            Some(secret) => {
134                self.token = secret.expose().to_string();
135                self.configured = true;
136                Ok(())
137            }
138            None => Err(crate::ConfigStoreError::Validation(
139                "referenced broker credential is unavailable".to_string(),
140            )),
141        }
142    }
143}
144
145impl Config {
146    // ── Proxy auth ─────────────────────────────────────────────────────
147
148    /// Populate `proxy_auth` (plaintext) from `proxy_auth_encrypted` if present.
149    ///
150    /// Many parts of the code rely on `proxy_auth` being hydrated in-memory so
151    /// we can re-encrypt deterministically on save without ever persisting
152    /// plaintext credentials.
153    pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
154        if self.proxy_auth_credential_ref.is_some() {
155            self.proxy_auth_encrypted = None;
156            return;
157        }
158        if self.proxy_auth.is_some() {
159            return;
160        }
161
162        // Backward compatibility:
163        // Older Bodhi/Tauri builds persisted proxy auth as per-scheme encrypted fields:
164        // `http_proxy_auth_encrypted` / `https_proxy_auth_encrypted`.
165        //
166        // Those live under `extra` (flatten) in the unified config. Seed the new
167        // `proxy_auth_encrypted` field so the rest of the code can stay uniform.
168        if self
169            .proxy_auth_encrypted
170            .as_deref()
171            .map(|s| s.trim().is_empty())
172            .unwrap_or(true)
173        {
174            let legacy = self
175                .extra
176                .get("https_proxy_auth_encrypted")
177                .and_then(|v| v.as_str())
178                .or_else(|| {
179                    self.extra
180                        .get("http_proxy_auth_encrypted")
181                        .and_then(|v| v.as_str())
182                })
183                .map(|s| s.trim())
184                .filter(|s| !s.is_empty())
185                .map(|s| s.to_string());
186
187            if let Some(legacy) = legacy {
188                self.proxy_auth_encrypted = Some(legacy);
189            }
190        }
191
192        let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
193            return;
194        };
195
196        match crate::encryption::decrypt(encrypted) {
197            Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
198                Ok(auth) => {
199                    self.proxy_auth = Some(auth);
200                    // Once hydrated successfully, drop legacy keys so a future save writes only
201                    // the canonical `proxy_auth_encrypted` field.
202                    self.extra.remove("http_proxy_auth_encrypted");
203                    self.extra.remove("https_proxy_auth_encrypted");
204                }
205                Err(e) => tracing::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
206            },
207            Err(e) => tracing::warn!("Failed to decrypt proxy auth: {}", e),
208        }
209    }
210
211    /// Refresh `proxy_auth_encrypted` from the current in-memory `proxy_auth`.
212    ///
213    /// This is used both when persisting the config to disk and when generating
214    /// API responses that should never include plaintext proxy credentials.
215    pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
216        if self.proxy_auth_credential_ref.is_some() {
217            self.proxy_auth_encrypted = None;
218            return Ok(());
219        }
220        // Keep on-disk representation fully derived from the in-memory plaintext:
221        // - Some(auth)  => always (re-)encrypt and store `proxy_auth_encrypted`
222        // - None        => remove `proxy_auth_encrypted`
223        let Some(auth) = self.proxy_auth.as_ref() else {
224            self.proxy_auth_encrypted = None;
225            return Ok(());
226        };
227
228        let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
229        let encrypted =
230            crate::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
231        self.proxy_auth_encrypted = Some(encrypted);
232        Ok(())
233    }
234
235    /// Hydrate proxy authentication from its isolated credential-store entry.
236    /// The stored secret is the JSON representation of [`crate::ProxyAuth`].
237    pub fn hydrate_proxy_auth_from_store(&mut self, data_dir: &std::path::Path) -> Result<()> {
238        let store = crate::CredentialStore::open(data_dir);
239        self.hydrate_proxy_auth_from_resolver(&store)
240            .map_err(anyhow::Error::from)
241    }
242
243    pub(crate) fn hydrate_proxy_auth_from_snapshot(
244        &mut self,
245        snapshot: &crate::credential_store::CredentialDocumentLkg,
246    ) -> crate::ConfigStoreResult<()> {
247        self.hydrate_proxy_auth_from_resolver(snapshot)
248    }
249
250    fn hydrate_proxy_auth_from_resolver(
251        &mut self,
252        resolver: &impl CredentialResolver,
253    ) -> crate::ConfigStoreResult<()> {
254        let Some(reference) = self.proxy_auth_credential_ref.as_ref() else {
255            return Ok(());
256        };
257        let value = resolver.resolve(reference)?;
258        let Some(value) = value else {
259            self.proxy_auth = None;
260            return Ok(());
261        };
262        self.proxy_auth = Some(serde_json::from_str(value.expose()).map_err(|_| {
263            crate::ConfigStoreError::Validation("proxy auth credential is invalid".to_string())
264        })?);
265        self.proxy_auth_encrypted = None;
266        Ok(())
267    }
268
269    // ── Provider API keys ──────────────────────────────────────────────
270
271    pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
272        if let Some(openai) = self.providers.openai.as_mut() {
273            if openai.api_key.trim().is_empty() {
274                if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
275                    match crate::encryption::decrypt(encrypted) {
276                        Ok(value) => openai.api_key = value,
277                        Err(e) => tracing::warn!("Failed to decrypt OpenAI api_key: {}", e),
278                    }
279                }
280            }
281        }
282
283        if let Some(anthropic) = self.providers.anthropic.as_mut() {
284            if anthropic.api_key.trim().is_empty() {
285                if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
286                    match crate::encryption::decrypt(encrypted) {
287                        Ok(value) => anthropic.api_key = value,
288                        Err(e) => tracing::warn!("Failed to decrypt Anthropic api_key: {}", e),
289                    }
290                }
291            }
292        }
293
294        if let Some(gemini) = self.providers.gemini.as_mut() {
295            if gemini.api_key.trim().is_empty() {
296                if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
297                    match crate::encryption::decrypt(encrypted) {
298                        Ok(value) => gemini.api_key = value,
299                        Err(e) => tracing::warn!("Failed to decrypt Gemini api_key: {}", e),
300                    }
301                }
302            }
303        }
304
305        if let Some(bodhi) = self.providers.bodhi.as_mut() {
306            if bodhi.api_key.trim().is_empty() {
307                if let Some(encrypted) = bodhi.api_key_encrypted.as_deref() {
308                    match crate::encryption::decrypt(encrypted) {
309                        Ok(value) => bodhi.api_key = value,
310                        Err(e) => tracing::warn!("Failed to decrypt Bodhi api_key: {}", e),
311                    }
312                }
313            }
314        }
315    }
316
317    /// Resolve built-in provider and provider-instance credential references
318    /// after legacy ciphertext hydration. Existing in-memory values (notably
319    /// environment overrides) retain precedence.
320    pub fn hydrate_provider_credentials_from_store(
321        &mut self,
322        data_dir: &std::path::Path,
323    ) -> crate::ConfigStoreResult<()> {
324        let store = crate::CredentialStore::open(data_dir);
325        self.hydrate_provider_credentials_from_resolver(&store)
326    }
327
328    pub(crate) fn hydrate_provider_credentials_from_snapshot(
329        &mut self,
330        snapshot: &crate::credential_store::CredentialDocumentLkg,
331    ) -> crate::ConfigStoreResult<()> {
332        self.hydrate_provider_credentials_from_resolver(snapshot)
333    }
334
335    fn hydrate_provider_credentials_from_resolver(
336        &mut self,
337        resolver: &impl CredentialResolver,
338    ) -> crate::ConfigStoreResult<()> {
339        macro_rules! hydrate {
340            ($provider:expr) => {
341                if let Some(provider) = $provider {
342                    if provider.api_key.trim().is_empty() {
343                        if let Some(reference) = provider.credential_ref.as_ref() {
344                            provider.api_key = resolver
345                                .resolve(reference)?
346                                .ok_or_else(|| {
347                                    crate::ConfigStoreError::Validation(
348                                        "referenced provider credential is unavailable".to_string(),
349                                    )
350                                })?
351                                .expose()
352                                .to_string();
353                        }
354                    }
355                }
356            };
357        }
358        hydrate!(self.providers.openai.as_mut());
359        hydrate!(self.providers.anthropic.as_mut());
360        hydrate!(self.providers.gemini.as_mut());
361        hydrate!(self.providers.bodhi.as_mut());
362        for instance in self.provider_instances.values_mut() {
363            if instance.api_key.trim().is_empty() {
364                if let Some(reference) = instance.credential_ref.as_ref() {
365                    instance.api_key = resolver
366                        .resolve(reference)?
367                        .ok_or_else(|| {
368                            crate::ConfigStoreError::Validation(
369                                "referenced provider credential is unavailable".to_string(),
370                            )
371                        })?
372                        .expose()
373                        .to_string();
374                }
375            }
376        }
377        Ok(())
378    }
379
380    pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
381        // Env-injected keys (`api_key_from_env`) are runtime-only: leave
382        // `api_key_encrypted` untouched so they're never baked into config.json
383        // on save (which would otherwise persist the secret even after the env
384        // var is removed). (#253)
385        if let Some(openai) = self.providers.openai.as_mut() {
386            if openai.credential_ref.is_some() {
387                openai.api_key_encrypted = None;
388            } else if !openai.api_key_from_env {
389                let api_key = openai.api_key.trim();
390                // Only (re)encrypt when we actually hold a plaintext key. When the
391                // plaintext is empty because the stored ciphertext failed to
392                // decrypt at hydration (config.json moved across machines, a
393                // machine-id change, the ephemeral fallback key), DON'T null the
394                // ciphertext — that would permanently drop a working key the user
395                // never touched on the next unrelated save. #268.
396                if !api_key.is_empty() {
397                    openai.api_key_encrypted = Some(
398                        crate::encryption::encrypt(api_key)
399                            .context("Failed to encrypt OpenAI api_key")?,
400                    );
401                }
402            }
403        }
404
405        if let Some(anthropic) = self.providers.anthropic.as_mut() {
406            if anthropic.credential_ref.is_some() {
407                anthropic.api_key_encrypted = None;
408            } else if !anthropic.api_key_from_env {
409                let api_key = anthropic.api_key.trim();
410                // Empty plaintext → preserve existing ciphertext (see OpenAI above). #268.
411                if !api_key.is_empty() {
412                    anthropic.api_key_encrypted = Some(
413                        crate::encryption::encrypt(api_key)
414                            .context("Failed to encrypt Anthropic api_key")?,
415                    );
416                }
417            }
418        }
419
420        if let Some(gemini) = self.providers.gemini.as_mut() {
421            if gemini.credential_ref.is_some() {
422                gemini.api_key_encrypted = None;
423            } else if !gemini.api_key_from_env {
424                let api_key = gemini.api_key.trim();
425                // Empty plaintext → preserve existing ciphertext (see OpenAI above). #268.
426                if !api_key.is_empty() {
427                    gemini.api_key_encrypted = Some(
428                        crate::encryption::encrypt(api_key)
429                            .context("Failed to encrypt Gemini api_key")?,
430                    );
431                }
432            }
433        }
434
435        if let Some(bodhi) = self.providers.bodhi.as_mut() {
436            if bodhi.credential_ref.is_some() {
437                bodhi.api_key_encrypted = None;
438                return Ok(());
439            }
440            let api_key = bodhi.api_key.trim();
441            // Empty plaintext → preserve existing ciphertext (see OpenAI above). #268.
442            if !api_key.is_empty() {
443                bodhi.api_key_encrypted = Some(
444                    crate::encryption::encrypt(api_key)
445                        .context("Failed to encrypt Bodhi api_key")?,
446                );
447            }
448        }
449
450        Ok(())
451    }
452
453    // ── Provider instance API keys ─────────────────────────────────────
454
455    /// Hydrate plaintext `api_key` fields on provider instances from their
456    /// encrypted counterparts.
457    pub fn hydrate_provider_instance_api_keys_from_encrypted(&mut self) {
458        for (id, instance) in self.provider_instances.iter_mut() {
459            if instance.api_key.trim().is_empty() {
460                if let Some(encrypted) = instance.api_key_encrypted.as_deref() {
461                    match crate::encryption::decrypt(encrypted) {
462                        Ok(value) => instance.api_key = value,
463                        Err(e) => {
464                            tracing::warn!(instance_id = id, "Failed to decrypt api_key: {}", e)
465                        }
466                    }
467                }
468            }
469        }
470    }
471
472    /// Re-encrypt all provider instance API keys and write back to
473    /// `api_key_encrypted`. Used before persisting to disk.
474    pub fn refresh_provider_instance_api_keys_encrypted(&mut self) -> Result<()> {
475        for (id, instance) in self.provider_instances.iter_mut() {
476            if instance.credential_ref.is_some() {
477                instance.api_key_encrypted = None;
478                continue;
479            }
480            let api_key = instance.api_key.trim();
481            // Empty plaintext → preserve existing ciphertext (see
482            // refresh_provider_api_keys_encrypted). #268.
483            if !api_key.is_empty() {
484                instance.api_key_encrypted = Some(crate::encryption::encrypt(api_key).context(
485                    format!("Failed to encrypt api_key for provider instance '{}'", id),
486                )?);
487            }
488        }
489        Ok(())
490    }
491
492    /// Ref-backed provider instances are the only representation permitted in
493    /// ordinary config documents. Callers that introduce or clear an instance
494    /// key must use the recoverable credential transaction first.
495    pub fn ensure_provider_instance_credentials_isolated(&mut self) -> Result<()> {
496        for (id, instance) in &mut self.provider_instances {
497            if instance.credential_ref.is_some() {
498                instance.api_key_encrypted = None;
499                continue;
500            }
501            if !instance.api_key.trim().is_empty() || instance.api_key_encrypted.is_some() {
502                anyhow::bail!(
503                    "provider instance '{id}' secret requires credential transaction before persistence"
504                );
505            }
506        }
507        Ok(())
508    }
509
510    // ── MCP secrets ────────────────────────────────────────────────────
511
512    pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
513        for server in self.mcp.servers.iter_mut() {
514            match &mut server.transport {
515                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
516                    if stdio.env_encrypted.is_empty() {
517                        continue;
518                    }
519
520                    // Avoid borrow-checker gymnastics by iterating a cloned map.
521                    for (key, encrypted) in stdio.env_encrypted.clone() {
522                        let should_hydrate = stdio
523                            .env
524                            .get(&key)
525                            .map(|v| v.trim().is_empty())
526                            .unwrap_or(true);
527                        if !should_hydrate {
528                            continue;
529                        }
530
531                        match crate::encryption::decrypt(&encrypted) {
532                            Ok(value) => {
533                                stdio.env.insert(key, value);
534                            }
535                            Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
536                        }
537                    }
538                }
539                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
540                    for header in sse.headers.iter_mut() {
541                        if !header.value.trim().is_empty() {
542                            continue;
543                        }
544                        let Some(encrypted) = header.value_encrypted.as_deref() else {
545                            continue;
546                        };
547                        match crate::encryption::decrypt(encrypted) {
548                            Ok(value) => header.value = value,
549                            Err(e) => {
550                                tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
551                            }
552                        }
553                    }
554                }
555                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
556                    for header in sh.headers.iter_mut() {
557                        if !header.value.trim().is_empty() {
558                            continue;
559                        }
560                        let Some(encrypted) = header.value_encrypted.as_deref() else {
561                            continue;
562                        };
563                        match crate::encryption::decrypt(encrypted) {
564                            Ok(value) => header.value = value,
565                            Err(e) => {
566                                tracing::warn!(
567                                    "Failed to decrypt MCP StreamableHTTP header value: {}",
568                                    e
569                                )
570                            }
571                        }
572                    }
573                }
574            }
575        }
576    }
577
578    /// Resolve MCP env/header references without exposing credential values to
579    /// serialization or debug output.
580    pub fn hydrate_mcp_credentials_from_store(
581        &mut self,
582        data_dir: &std::path::Path,
583    ) -> crate::ConfigStoreResult<()> {
584        let store = crate::CredentialStore::open(data_dir);
585        self.hydrate_mcp_credentials_from_resolver(&store)
586    }
587
588    pub(crate) fn hydrate_mcp_credentials_from_snapshot(
589        &mut self,
590        snapshot: &crate::credential_store::CredentialDocumentLkg,
591    ) -> crate::ConfigStoreResult<()> {
592        self.hydrate_mcp_credentials_from_resolver(snapshot)
593    }
594
595    fn hydrate_mcp_credentials_from_resolver(
596        &mut self,
597        resolver: &impl CredentialResolver,
598    ) -> crate::ConfigStoreResult<()> {
599        for server in &mut self.mcp.servers {
600            match &mut server.transport {
601                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
602                    for (name, raw_reference) in &stdio.env_credential_refs {
603                        if stdio.env.get(name).is_some_and(|value| !value.is_empty()) {
604                            continue;
605                        }
606                        let reference = crate::CredentialRef::parse(raw_reference.clone())?;
607                        let secret = resolver.resolve(&reference)?.ok_or_else(|| {
608                            crate::ConfigStoreError::Validation(
609                                "referenced MCP credential is unavailable".to_string(),
610                            )
611                        })?;
612                        stdio.env.insert(name.clone(), secret.expose().to_string());
613                    }
614                }
615                bamboo_domain::mcp_config::TransportConfig::Sse(config) => {
616                    hydrate_header_credentials(resolver, &mut config.headers)?;
617                }
618                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(config) => {
619                    hydrate_header_credentials(resolver, &mut config.headers)?;
620                }
621            }
622        }
623        Ok(())
624    }
625
626    pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
627        for server in self.mcp.servers.iter_mut() {
628            match &mut server.transport {
629                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
630                    stdio.env_encrypted.clear();
631                    for (key, value) in &stdio.env {
632                        let encrypted = crate::encryption::encrypt(value).with_context(|| {
633                            format!("Failed to encrypt MCP stdio env var '{key}'")
634                        })?;
635                        stdio.env_encrypted.insert(key.clone(), encrypted);
636                    }
637                }
638                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
639                    for header in sse.headers.iter_mut() {
640                        let configured = !header.value.trim().is_empty();
641                        header.value_encrypted = if !configured {
642                            None
643                        } else {
644                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
645                                format!("Failed to encrypt MCP SSE header '{}'", header.name)
646                            })?)
647                        };
648                    }
649                }
650                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
651                    for header in sh.headers.iter_mut() {
652                        let configured = !header.value.trim().is_empty();
653                        header.value_encrypted = if !configured {
654                            None
655                        } else {
656                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
657                                format!(
658                                    "Failed to encrypt MCP StreamableHTTP header '{}'",
659                                    header.name
660                                )
661                            })?)
662                        };
663                    }
664                }
665            }
666        }
667
668        Ok(())
669    }
670
671    /// Project credential-ref-backed MCP runtime values to the root disk DTO.
672    /// Public serialization remains compatibility-oriented, but config.json
673    /// must never duplicate either hydrated plaintext or legacy ciphertext
674    /// once the isolated credential store is authoritative.
675    pub fn sanitize_mcp_credential_refs_for_disk(&mut self) {
676        for server in &mut self.mcp.servers {
677            match &mut server.transport {
678                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
679                    for name in stdio
680                        .env_credential_refs
681                        .keys()
682                        .cloned()
683                        .collect::<Vec<_>>()
684                    {
685                        stdio.env.remove(&name);
686                        stdio.env_encrypted.remove(&name);
687                    }
688                }
689                bamboo_domain::mcp_config::TransportConfig::Sse(config) => {
690                    for header in &mut config.headers {
691                        if header.credential_ref.is_some() {
692                            header.value.clear();
693                            header.value_encrypted = None;
694                        }
695                    }
696                }
697                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(config) => {
698                    for header in &mut config.headers {
699                        if header.credential_ref.is_some() {
700                            header.value.clear();
701                            header.value_encrypted = None;
702                        }
703                    }
704                }
705            }
706        }
707    }
708
709    // ── Env vars encryption ────────────────────────────────────────────
710
711    /// Decrypt secret env vars into in-memory plaintext after loading config.
712    pub fn hydrate_env_vars_from_encrypted(&mut self) {
713        for entry in &mut self.env_vars {
714            if !entry.secret {
715                continue;
716            }
717            if !entry.value.trim().is_empty() {
718                // Already has plaintext (e.g. in-memory update).
719                continue;
720            }
721            let Some(encrypted) = &entry.value_encrypted else {
722                continue;
723            };
724            match crate::encryption::decrypt(encrypted) {
725                Ok(value) => entry.value = value,
726                Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
727            }
728        }
729    }
730
731    /// Resolve secret env values from the isolated credential store. A
732    /// configured reference must resolve; silently publishing an empty value
733    /// would make Bash/session behavior diverge from durable metadata.
734    pub fn hydrate_env_var_credentials_from_store(
735        &mut self,
736        data_dir: &std::path::Path,
737    ) -> crate::ConfigStoreResult<()> {
738        let store = crate::CredentialStore::open(data_dir);
739        self.hydrate_env_var_credentials_from_resolver(&store)
740    }
741
742    pub(crate) fn hydrate_env_var_credentials_from_snapshot(
743        &mut self,
744        snapshot: &crate::credential_store::CredentialDocumentLkg,
745    ) -> crate::ConfigStoreResult<()> {
746        self.hydrate_env_var_credentials_from_resolver(snapshot)
747    }
748
749    fn hydrate_env_var_credentials_from_resolver(
750        &mut self,
751        resolver: &impl CredentialResolver,
752    ) -> crate::ConfigStoreResult<()> {
753        for entry in &mut self.env_vars {
754            if !entry.secret {
755                entry.credential_ref = None;
756                entry.configured = !entry.value.is_empty();
757                continue;
758            }
759            let Some(reference) = entry.credential_ref.as_ref() else {
760                entry.configured = false;
761                continue;
762            };
763            match resolver.resolve(reference)? {
764                Some(secret) => {
765                    entry.value = secret.expose().to_string();
766                    entry.configured = true;
767                }
768                None if entry.configured => {
769                    return Err(crate::ConfigStoreError::Validation(
770                        "referenced env credential is unavailable".to_string(),
771                    ));
772                }
773                None => {
774                    entry.value.clear();
775                }
776            }
777        }
778        Ok(())
779    }
780
781    /// Re-encrypt secret env vars before persisting to disk.
782    pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
783        for entry in &mut self.env_vars {
784            if entry.secret && entry.credential_ref.is_some() {
785                entry.value_encrypted = None;
786            } else if entry.secret && !entry.value.trim().is_empty() {
787                entry.value_encrypted = Some(
788                    crate::encryption::encrypt(&entry.value)
789                        .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
790                );
791            } else if !entry.secret {
792                entry.value_encrypted = None;
793            }
794        }
795        Ok(())
796    }
797
798    /// Clear plaintext values for secrets before serialization to disk.
799    pub fn sanitize_env_vars_for_disk(&mut self) {
800        for entry in &mut self.env_vars {
801            if entry.secret {
802                entry.value = String::new();
803                entry.value_encrypted = None;
804            } else {
805                entry.credential_ref = None;
806                entry.configured = !entry.value.is_empty();
807            }
808        }
809    }
810
811    // ── Broker client token encryption ─────────────────────────────────
812
813    /// Decrypt the broker token into in-memory plaintext after loading config.
814    pub fn hydrate_broker_token_from_encrypted(&mut self) {
815        let Some(broker) = self.subagents.broker.as_mut() else {
816            return;
817        };
818        if !broker.token.trim().is_empty() {
819            return; // already has plaintext
820        }
821        if let Some(encrypted) = &broker.token_encrypted {
822            match crate::encryption::decrypt(encrypted) {
823                Ok(value) => broker.token = value,
824                Err(e) => tracing::warn!("Failed to decrypt broker token: {}", e),
825            }
826        }
827    }
828
829    /// Re-encrypt the broker token before persisting to disk.
830    pub fn refresh_broker_token_encrypted(&mut self) -> Result<()> {
831        let Some(broker) = self.subagents.broker.as_mut() else {
832            return Ok(());
833        };
834        if broker.token.trim().is_empty() {
835            // Keep any existing ciphertext (a redacted round-trip never re-sends it).
836            return Ok(());
837        }
838        broker.token_encrypted = Some(
839            crate::encryption::encrypt(&broker.token).context("Failed to encrypt broker token")?,
840        );
841        Ok(())
842    }
843
844    /// Clear the plaintext broker token before serialization to disk.
845    pub fn sanitize_broker_token_for_disk(&mut self) {
846        if let Some(broker) = self.subagents.broker.as_mut() {
847            broker.token = String::new();
848        }
849    }
850
851    // ── Notification channel secrets (ntfy token, Bark device key) ─────
852
853    /// Decrypt legacy notification-channel ciphertext into memory so the
854    /// credential migration can move it into the isolated store. New writes
855    /// never serialize these ciphertext fields.
856    pub fn hydrate_notifications_from_encrypted(&mut self) {
857        let ntfy = &mut self.notifications.ntfy;
858        if ntfy
859            .token
860            .as_deref()
861            .map(str::trim)
862            .unwrap_or("")
863            .is_empty()
864        {
865            if let Some(encrypted) = ntfy.token_encrypted.as_deref() {
866                match crate::encryption::decrypt(encrypted) {
867                    Ok(value) => ntfy.token = Some(value),
868                    Err(e) => tracing::warn!("Failed to decrypt ntfy token: {}", e),
869                }
870            }
871        }
872
873        let bark = &mut self.notifications.bark;
874        if bark
875            .device_key
876            .as_deref()
877            .map(str::trim)
878            .unwrap_or("")
879            .is_empty()
880        {
881            if let Some(encrypted) = bark.device_key_encrypted.as_deref() {
882                match crate::encryption::decrypt(encrypted) {
883                    Ok(value) => bark.device_key = Some(value),
884                    Err(e) => tracing::warn!("Failed to decrypt Bark device key: {}", e),
885                }
886            }
887        }
888    }
889
890    /// Resolve notification channel credentials after legacy migration. A
891    /// configured reference must resolve; callers treat any failure as a
892    /// fail-closed notification configuration instead of silently disabling
893    /// authentication for a protected endpoint.
894    pub fn hydrate_notification_credentials_from_store(
895        &mut self,
896        data_dir: &std::path::Path,
897    ) -> crate::ConfigStoreResult<()> {
898        let store = crate::CredentialStore::open(data_dir);
899        self.hydrate_notification_credentials_from_resolver(&store)
900    }
901
902    pub(crate) fn hydrate_notification_credentials_from_snapshot(
903        &mut self,
904        snapshot: &crate::credential_store::CredentialDocumentLkg,
905    ) -> crate::ConfigStoreResult<()> {
906        self.hydrate_notification_credentials_from_resolver(snapshot)
907    }
908
909    fn hydrate_notification_credentials_from_resolver(
910        &mut self,
911        resolver: &impl CredentialResolver,
912    ) -> crate::ConfigStoreResult<()> {
913        let reference_counts = crate::credential_store::config_credential_ref_counts(self)?;
914        for reference in [
915            self.notifications.ntfy.credential_ref.as_ref(),
916            self.notifications.bark.credential_ref.as_ref(),
917        ]
918        .into_iter()
919        .flatten()
920        {
921            if reference_counts.get(reference).copied() != Some(1) {
922                return Err(crate::ConfigStoreError::Validation(
923                    "notification credential reference is shared by another config consumer"
924                        .to_string(),
925                ));
926            }
927        }
928        let ntfy = &mut self.notifications.ntfy;
929        if let Some(reference) = ntfy.credential_ref.as_ref() {
930            match resolver.resolve(reference)? {
931                Some(secret) => {
932                    ntfy.token = Some(secret.expose().to_string());
933                    ntfy.configured = true;
934                }
935                None if ntfy.configured => {
936                    return Err(crate::ConfigStoreError::Validation(
937                        "referenced ntfy credential is unavailable".to_string(),
938                    ));
939                }
940                None => ntfy.token = None,
941            }
942        } else if ntfy.configured {
943            return Err(crate::ConfigStoreError::Validation(
944                "configured ntfy credential reference is missing".to_string(),
945            ));
946        }
947
948        let bark = &mut self.notifications.bark;
949        if let Some(reference) = bark.credential_ref.as_ref() {
950            match resolver.resolve(reference)? {
951                Some(secret) => {
952                    bark.device_key = Some(secret.expose().to_string());
953                    bark.configured = true;
954                }
955                None if bark.configured => {
956                    return Err(crate::ConfigStoreError::Validation(
957                        "referenced Bark credential is unavailable".to_string(),
958                    ));
959                }
960                None => bark.device_key = None,
961            }
962        } else if bark.configured {
963            return Err(crate::ConfigStoreError::Validation(
964                "configured Bark credential reference is missing".to_string(),
965            ));
966        }
967        Ok(())
968    }
969
970    /// Maintain legacy in-memory ciphertext compatibility until credential
971    /// migration runs. New writes sanitize these fields and persist only a
972    /// credential reference plus configured metadata.
973    pub fn refresh_notifications_encrypted(&mut self) -> Result<()> {
974        let ntfy = &mut self.notifications.ntfy;
975        if ntfy.credential_ref.is_some() {
976            ntfy.token_encrypted = None;
977            ntfy.configured = ntfy
978                .token
979                .as_deref()
980                .is_some_and(|value| !value.trim().is_empty())
981                || ntfy.configured;
982        } else {
983            let token = ntfy.token.as_deref().unwrap_or("").trim();
984            if !token.is_empty() {
985                ntfy.token_encrypted = Some(
986                    crate::encryption::encrypt(token).context("Failed to encrypt ntfy token")?,
987                );
988            }
989        }
990
991        let bark = &mut self.notifications.bark;
992        if bark.credential_ref.is_some() {
993            bark.device_key_encrypted = None;
994            bark.configured = bark
995                .device_key
996                .as_deref()
997                .is_some_and(|value| !value.trim().is_empty())
998                || bark.configured;
999        } else {
1000            let device_key = bark.device_key.as_deref().unwrap_or("").trim();
1001            if !device_key.is_empty() {
1002                bark.device_key_encrypted = Some(
1003                    crate::encryption::encrypt(device_key)
1004                        .context("Failed to encrypt Bark device key")?,
1005                );
1006            }
1007        }
1008
1009        Ok(())
1010    }
1011
1012    /// Clear notification plaintext and legacy ciphertext before ordinary
1013    /// root serialization. Only credential references and configured metadata
1014    /// may leave the process.
1015    pub fn sanitize_notifications_for_disk(&mut self) {
1016        self.notifications.ntfy.token = None;
1017        self.notifications.ntfy.token_encrypted = None;
1018        self.notifications.bark.device_key = None;
1019        self.notifications.bark.device_key_encrypted = None;
1020    }
1021
1022    // ── bamboo-connect platform tokens (Telegram bot token, etc.) ───────
1023
1024    /// Decrypt every configured platform's token (and Feishu `app_secret`)
1025    /// into in-memory plaintext after loading config. Mirrors
1026    /// [`Config::hydrate_notifications_from_encrypted`]: both fields are
1027    /// `#[serde(skip_serializing)]` (never on disk), so this is the only way
1028    /// they get populated after a fresh load.
1029    pub fn hydrate_connect_platform_tokens_from_encrypted(&mut self) {
1030        for platform in &mut self.connect.platforms {
1031            let has_plaintext = platform
1032                .token
1033                .as_deref()
1034                .map(str::trim)
1035                .map(|value| !value.is_empty())
1036                .unwrap_or(false);
1037            if !has_plaintext {
1038                if let Some(encrypted) = platform.token_encrypted.as_deref() {
1039                    match crate::encryption::decrypt(encrypted) {
1040                        Ok(value) => platform.token = Some(value),
1041                        Err(e) => tracing::warn!(
1042                            "Failed to decrypt connect platform '{}' token: {}",
1043                            platform.platform_type,
1044                            e
1045                        ),
1046                    }
1047                }
1048            }
1049
1050            let has_app_secret_plaintext = platform
1051                .app_secret
1052                .as_deref()
1053                .map(str::trim)
1054                .map(|value| !value.is_empty())
1055                .unwrap_or(false);
1056            if !has_app_secret_plaintext {
1057                if let Some(encrypted) = platform.app_secret_encrypted.as_deref() {
1058                    match crate::encryption::decrypt(encrypted) {
1059                        Ok(value) => platform.app_secret = Some(value),
1060                        Err(e) => tracing::warn!(
1061                            "Failed to decrypt connect platform '{}' app_secret: {}",
1062                            platform.platform_type,
1063                            e
1064                        ),
1065                    }
1066                }
1067            }
1068        }
1069    }
1070
1071    /// Resolve bamboo-connect token/app-secret references from the isolated
1072    /// credential store. A configured reference is fail-closed: publishing an
1073    /// empty credential would make the bridge appear live while authentication
1074    /// is guaranteed to fail.
1075    pub fn hydrate_connect_credentials_from_store(
1076        &mut self,
1077        data_dir: &std::path::Path,
1078    ) -> crate::ConfigStoreResult<()> {
1079        let store = crate::CredentialStore::open(data_dir);
1080        let allow_legacy_runtime_value = !crate::section_layout_is_active(data_dir)?;
1081        self.hydrate_connect_credentials_from_resolver(&store, allow_legacy_runtime_value)
1082    }
1083
1084    pub(crate) fn hydrate_connect_credentials_from_snapshot(
1085        &mut self,
1086        snapshot: &crate::credential_store::CredentialDocumentLkg,
1087    ) -> crate::ConfigStoreResult<()> {
1088        self.hydrate_connect_credentials_from_resolver(snapshot, false)
1089    }
1090
1091    fn hydrate_connect_credentials_from_resolver(
1092        &mut self,
1093        resolver: &impl CredentialResolver,
1094        allow_legacy_runtime_value: bool,
1095    ) -> crate::ConfigStoreResult<()> {
1096        for platform in &mut self.connect.platforms {
1097            hydrate_optional_connect_secret(
1098                resolver,
1099                platform.token_credential_ref.as_ref(),
1100                platform.token_configured,
1101                &mut platform.token,
1102                allow_legacy_runtime_value,
1103            )?;
1104            hydrate_optional_connect_secret(
1105                resolver,
1106                platform.app_secret_credential_ref.as_ref(),
1107                platform.app_secret_configured,
1108                &mut platform.app_secret,
1109                allow_legacy_runtime_value,
1110            )?;
1111            platform.token_encrypted = None;
1112            platform.app_secret_encrypted = None;
1113        }
1114        Ok(())
1115    }
1116
1117    /// Resolve password/device verifier records from the isolated credential
1118    /// store. Any configured-but-unavailable record fails closed for the whole
1119    /// access domain so middleware never silently weakens authentication.
1120    pub fn hydrate_access_control_credentials_from_store(
1121        &mut self,
1122        data_dir: &std::path::Path,
1123    ) -> crate::ConfigStoreResult<()> {
1124        let store = crate::CredentialStore::open(data_dir);
1125        self.hydrate_access_control_credentials_from_resolver(&store)
1126    }
1127
1128    pub(crate) fn hydrate_access_control_credentials_from_snapshot(
1129        &mut self,
1130        snapshot: &crate::credential_store::CredentialDocumentLkg,
1131    ) -> crate::ConfigStoreResult<()> {
1132        self.hydrate_access_control_credentials_from_resolver(snapshot)
1133    }
1134
1135    fn hydrate_access_control_credentials_from_resolver(
1136        &mut self,
1137        resolver: &impl CredentialResolver,
1138    ) -> crate::ConfigStoreResult<()> {
1139        let Some(access) = self.access_control.as_mut() else {
1140            return Ok(());
1141        };
1142        match access.password_credential_ref.as_ref() {
1143            Some(reference) => {
1144                if reference != &access_password_credential_ref()? || !access.password_configured {
1145                    return Err(crate::ConfigStoreError::Validation(
1146                        "access-control password credential metadata is invalid".to_string(),
1147                    ));
1148                }
1149                let secret = resolver.resolve(reference)?.ok_or_else(|| {
1150                    crate::ConfigStoreError::Validation(
1151                        "access-control password verifier is unavailable".to_string(),
1152                    )
1153                })?;
1154                let record = decode_access_verifier(secret.expose())?;
1155                access.password_hash = Some(record.hash);
1156                access.password_salt = Some(record.salt);
1157            }
1158            None if access.password_configured || access.password_enabled => {
1159                return Err(crate::ConfigStoreError::Validation(
1160                    "access-control password verifier metadata is incomplete".to_string(),
1161                ));
1162            }
1163            None => {
1164                access.password_hash = None;
1165                access.password_salt = None;
1166            }
1167        }
1168        for device in &mut access.devices {
1169            let reference = device.token_credential_ref.as_ref().ok_or_else(|| {
1170                crate::ConfigStoreError::Validation(
1171                    "access-control device verifier metadata is incomplete".to_string(),
1172                )
1173            })?;
1174            if reference != &access_device_credential_ref(&device.device_id)?
1175                || !device.token_configured
1176            {
1177                return Err(crate::ConfigStoreError::Validation(
1178                    "access-control device credential metadata is invalid".to_string(),
1179                ));
1180            }
1181            let secret = resolver.resolve(reference)?.ok_or_else(|| {
1182                crate::ConfigStoreError::Validation(
1183                    "access-control device verifier is unavailable".to_string(),
1184                )
1185            })?;
1186            let record = decode_access_verifier(secret.expose())?;
1187            device.token_hash = record.hash;
1188            device.token_salt = record.salt;
1189        }
1190        Ok(())
1191    }
1192
1193    pub fn clear_access_control_runtime_verifiers(&mut self) {
1194        if let Some(access) = self.access_control.as_mut() {
1195            access.password_hash = None;
1196            access.password_salt = None;
1197            for device in &mut access.devices {
1198                device.token_hash.clear();
1199                device.token_salt.clear();
1200            }
1201        }
1202    }
1203
1204    /// Remove runtime verifier material from the durable access projection.
1205    pub fn sanitize_access_control_for_disk(&mut self) {
1206        self.clear_access_control_runtime_verifiers();
1207    }
1208
1209    /// Re-encrypt every configured platform's token (and Feishu `app_secret`)
1210    /// from current in-memory plaintext before persisting to disk. Mirrors
1211    /// [`Config::refresh_notifications_encrypted`]: an empty/absent plaintext
1212    /// leaves any existing ciphertext intact (a redacted round-trip where the
1213    /// client never re-sent the secret keeps it).
1214    pub fn refresh_connect_platform_tokens_encrypted(&mut self) -> Result<()> {
1215        for platform in &mut self.connect.platforms {
1216            if platform.token_credential_ref.is_some() {
1217                platform.token_encrypted = None;
1218            }
1219            let token = platform.token.as_deref().unwrap_or("").trim();
1220            if platform.token_credential_ref.is_none() && !token.is_empty() {
1221                platform.token_encrypted =
1222                    Some(crate::encryption::encrypt(token).with_context(|| {
1223                        format!(
1224                            "Failed to encrypt connect platform '{}' token",
1225                            platform.platform_type
1226                        )
1227                    })?);
1228            }
1229
1230            if platform.app_secret_credential_ref.is_some() {
1231                platform.app_secret_encrypted = None;
1232            }
1233            let app_secret = platform.app_secret.as_deref().unwrap_or("").trim();
1234            if platform.app_secret_credential_ref.is_none() && !app_secret.is_empty() {
1235                platform.app_secret_encrypted =
1236                    Some(crate::encryption::encrypt(app_secret).with_context(|| {
1237                        format!(
1238                            "Failed to encrypt connect platform '{}' app_secret",
1239                            platform.platform_type
1240                        )
1241                    })?);
1242            }
1243        }
1244        Ok(())
1245    }
1246
1247    /// Remove runtime plaintext and legacy ciphertext from the durable connect
1248    /// projection. Only stable refs and configured metadata remain.
1249    pub fn sanitize_connect_credentials_for_disk(&mut self) {
1250        for platform in &mut self.connect.platforms {
1251            platform.token = None;
1252            platform.token_encrypted = None;
1253            platform.app_secret = None;
1254            platform.app_secret_encrypted = None;
1255        }
1256    }
1257
1258    /// Restore env-sourced provider `api_key`s that a serde round-trip dropped.
1259    ///
1260    /// `api_key` is `#[serde(skip_serializing)]`, so serializing `previous` and
1261    /// deserializing it back — as the settings-PATCH merge in
1262    /// `config_manager::build_merged_config` does — loses every provider's
1263    /// plaintext key. `hydrate_provider_api_keys_from_encrypted` then restores
1264    /// only keys that have a persisted ciphertext, which an env-injected key
1265    /// never has (that's the #253 design). Without this, a PATCH to ANY provider
1266    /// setting silently blanks the live env-sourced key of every OTHER provider
1267    /// until the process restarts.
1268    ///
1269    /// Copies the key back from `previous` for each provider still flagged
1270    /// env-sourced there whose key wasn't explicitly re-set by the patch (i.e. is
1271    /// empty after the round-trip), so an explicit `api_key` in the patch still
1272    /// wins. #373.
1273    pub fn preserve_env_sourced_provider_keys(&mut self, previous: &Config) {
1274        macro_rules! restore_env_key {
1275            ($field:ident) => {
1276                if let (Some(current), Some(prev)) = (
1277                    self.providers.$field.as_mut(),
1278                    previous.providers.$field.as_ref(),
1279                ) {
1280                    if prev.api_key_from_env && current.api_key.trim().is_empty() {
1281                        current.api_key = prev.api_key.clone();
1282                        current.api_key_from_env = true;
1283                    }
1284                }
1285            };
1286        }
1287        restore_env_key!(openai);
1288        restore_env_key!(anthropic);
1289        restore_env_key!(gemini);
1290    }
1291
1292    /// Preserve freshly-created provider-instance plaintext keys that are lost
1293    /// during the compatibility JSON round-trip.
1294    ///
1295    /// Provider instance `api_key` fields are `#[serde(skip_serializing)]`, so a
1296    /// round-trip through `to_compatibility_value()` / `from_value()` in
1297    /// `config_manager::build_merged_config` drops them. If no ciphertext was
1298    /// persisted yet (newly created instance), copy key material from the live
1299    /// `previous` config for instances not explicitly touched by the patch so
1300    /// the key is not silently cleared before the next save.
1301    pub fn preserve_provider_instance_plaintext_keys(
1302        &mut self,
1303        previous: &Config,
1304        intents: &ProviderApiKeyIntents,
1305    ) {
1306        for (id, instance) in self.provider_instances.iter_mut() {
1307            if intents.provider_instances.contains(id) {
1308                continue;
1309            }
1310            if !instance.api_key.trim().is_empty() || instance.api_key_encrypted.is_some() {
1311                continue;
1312            }
1313            if let Some(previous) = previous.provider_instances.get(id) {
1314                if !previous.api_key.trim().is_empty() || previous.api_key_encrypted.is_some() {
1315                    instance.api_key = previous.api_key.clone();
1316                    instance.api_key_encrypted = previous.api_key_encrypted.clone();
1317                }
1318            }
1319        }
1320    }
1321
1322    /// Re-encrypt every secret domain's `*_encrypted` field from current
1323    /// in-memory plaintext, without the disk-only sanitization steps.
1324    ///
1325    /// `Config::save_to_dir` runs these refreshes on a save-time clone, so the
1326    /// live in-memory config never sees the resulting ciphertext: a provider
1327    /// instance created over HTTP keeps `api_key_encrypted: None` in memory for
1328    /// the rest of the session. Any code that then serializes the live config
1329    /// and deserializes it back — the settings-PATCH merge in
1330    /// `config_manager::build_merged_config` — drops the
1331    /// `#[serde(skip_serializing)]` plaintext and is left with neither field,
1332    /// permanently losing the key on the next persist (#516). Call this after
1333    /// mutating the live config so ciphertext stays in sync with plaintext.
1334    pub fn refresh_encrypted_secrets(&mut self) -> Result<()> {
1335        self.refresh_proxy_auth_encrypted()?;
1336        self.refresh_provider_api_keys_encrypted()?;
1337        self.refresh_provider_instance_api_keys_encrypted()?;
1338        self.refresh_env_vars_encrypted()?;
1339        self.refresh_cluster_fabric_encrypted()?;
1340        self.refresh_notifications_encrypted()?;
1341        self.refresh_connect_platform_tokens_encrypted()?;
1342        Ok(())
1343    }
1344}
1345
1346fn hydrate_optional_connect_secret(
1347    resolver: &impl CredentialResolver,
1348    reference: Option<&crate::CredentialRef>,
1349    configured: bool,
1350    target: &mut Option<String>,
1351    allow_legacy_runtime_value: bool,
1352) -> crate::ConfigStoreResult<()> {
1353    match reference {
1354        Some(reference) => match resolver.resolve(reference)? {
1355            Some(secret) => *target = Some(secret.expose().to_string()),
1356            None if configured => {
1357                return Err(crate::ConfigStoreError::Validation(
1358                    "referenced connect credential is unavailable".to_string(),
1359                ));
1360            }
1361            None => *target = None,
1362        },
1363        None if configured => {
1364            return Err(crate::ConfigStoreError::Validation(
1365                "connect credential metadata is inconsistent".to_string(),
1366            ));
1367        }
1368        None if allow_legacy_runtime_value && target.is_some() => {}
1369        None => *target = None,
1370    }
1371    Ok(())
1372}