cuenv_secrets/
resolved.rs

1//! Resolved secrets with fingerprinting support
2
3use crate::{
4    BatchSecrets, SaltConfig, SecretError, SecretResolver, SecretSpec, compute_secret_fingerprint,
5};
6use std::collections::HashMap;
7
8/// Resolved secrets ready for injection
9#[derive(Debug, Clone, Default)]
10pub struct ResolvedSecrets {
11    /// Secret name -> resolved value
12    pub values: HashMap<String, String>,
13    /// Secret name -> HMAC fingerprint (for cache keys)
14    pub fingerprints: HashMap<String, String>,
15}
16
17impl ResolvedSecrets {
18    /// Create empty resolved secrets
19    #[must_use]
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Resolve secrets using a resolver with salt configuration
25    ///
26    /// # Arguments
27    /// * `resolver` - The secret resolver to use
28    /// * `secrets` - Map of secret names to their configuration
29    /// * `salt_config` - Salt configuration for fingerprinting
30    ///
31    /// # Errors
32    /// Returns error if a secret cannot be resolved or if salt is missing
33    /// when secrets have `cache_key: true`
34    pub async fn resolve<R: SecretResolver>(
35        resolver: &R,
36        secrets: &HashMap<String, SecretSpec>,
37        salt_config: &SaltConfig,
38    ) -> Result<Self, SecretError> {
39        let mut values = HashMap::new();
40        let mut fingerprints = HashMap::new();
41
42        // Check if any secret requires cache key and salt is missing
43        let needs_salt = secrets.values().any(|c| c.cache_key);
44        if needs_salt && !salt_config.has_salt() {
45            return Err(SecretError::MissingSalt);
46        }
47
48        for (name, spec) in secrets {
49            let value = resolver.resolve(name, spec).await?;
50
51            // Compute fingerprint if secret affects cache
52            if spec.cache_key {
53                // Warn if secret is too short (but don't fail)
54                if value.len() < 4 {
55                    tracing::warn!(
56                        secret = %name,
57                        len = value.len(),
58                        "Secret is too short for safe cache key inclusion"
59                    );
60                }
61
62                // Use write_salt for computing fingerprints (current salt preferred)
63                let fingerprint = compute_secret_fingerprint(
64                    name,
65                    &value,
66                    salt_config.write_salt().unwrap_or(""),
67                );
68                fingerprints.insert(name.clone(), fingerprint);
69            }
70
71            values.insert(name.clone(), value);
72        }
73
74        Ok(Self {
75            values,
76            fingerprints,
77        })
78    }
79
80    /// Create from a `BatchSecrets` instance.
81    ///
82    /// This consumes the batch and converts it to the legacy format.
83    /// Note that this exposes the secret values from the secure storage.
84    #[must_use]
85    pub fn from_batch(batch: BatchSecrets) -> Self {
86        batch.into_resolved_secrets()
87    }
88
89    /// Resolve secrets using batch resolution with a resolver.
90    ///
91    /// This is the preferred method for resolving multiple secrets efficiently.
92    /// It uses the resolver's batch resolution method which may use native
93    /// batch APIs (e.g., AWS `BatchGetSecretValue`, 1Password `Secrets.ResolveAll`).
94    ///
95    /// # Arguments
96    /// * `resolver` - The secret resolver to use
97    /// * `secrets` - Map of secret names to their configuration
98    /// * `salt_config` - Salt configuration for fingerprinting
99    ///
100    /// # Errors
101    /// Returns error if a secret cannot be resolved or if salt is missing
102    /// when secrets have `cache_key: true`
103    pub async fn resolve_batch<R: SecretResolver>(
104        resolver: &R,
105        secrets: &HashMap<String, SecretSpec>,
106        salt_config: &SaltConfig,
107    ) -> Result<Self, SecretError> {
108        let batch = crate::batch::resolve_batch(resolver, secrets, salt_config).await?;
109        Ok(Self::from_batch(batch))
110    }
111
112    /// Check if any secrets were resolved
113    #[must_use]
114    pub fn is_empty(&self) -> bool {
115        self.values.is_empty()
116    }
117
118    /// Get a resolved secret value by name
119    #[must_use]
120    pub fn get(&self, name: &str) -> Option<&str> {
121        self.values.get(name).map(String::as_str)
122    }
123
124    /// Check if a cached fingerprint matches with salt rotation support
125    ///
126    /// During salt rotation, this checks if the cached fingerprint matches
127    /// using either the current or previous salt. This allows cache hits
128    /// during the rotation window.
129    ///
130    /// # Arguments
131    /// * `name` - Secret name
132    /// * `cached_fingerprint` - Fingerprint from cache
133    /// * `salt_config` - Salt configuration with current and optional previous salt
134    ///
135    /// # Returns
136    /// `true` if the fingerprint matches with either salt, `false` otherwise
137    #[must_use]
138    pub fn fingerprint_matches(
139        &self,
140        name: &str,
141        cached_fingerprint: &str,
142        salt_config: &SaltConfig,
143    ) -> bool {
144        let Some(value) = self.values.get(name) else {
145            return false;
146        };
147
148        // Check against current salt
149        if let Some(current) = &salt_config.current {
150            let current_fp = compute_secret_fingerprint(name, value, current);
151            if current_fp == cached_fingerprint {
152                return true;
153            }
154        }
155
156        // Check against previous salt (for rotation window)
157        if let Some(previous) = &salt_config.previous {
158            let previous_fp = compute_secret_fingerprint(name, value, previous);
159            if previous_fp == cached_fingerprint {
160                tracing::debug!(
161                    secret = %name,
162                    "Cache hit using previous salt - rotation in progress"
163                );
164                return true;
165            }
166        }
167
168        false
169    }
170
171    /// Compute fingerprints using both current and previous salts
172    ///
173    /// Returns a tuple of (`current_fingerprint`, `previous_fingerprint`) for cache validation.
174    /// Either may be None if the corresponding salt is not configured.
175    #[must_use]
176    pub fn compute_fingerprints_for_validation(
177        &self,
178        name: &str,
179        salt_config: &SaltConfig,
180    ) -> (Option<String>, Option<String>) {
181        let Some(value) = self.values.get(name) else {
182            return (None, None);
183        };
184
185        let current_fp = salt_config
186            .current
187            .as_ref()
188            .map(|salt| compute_secret_fingerprint(name, value, salt));
189
190        let previous_fp = salt_config
191            .previous
192            .as_ref()
193            .map(|salt| compute_secret_fingerprint(name, value, salt));
194
195        (current_fp, previous_fp)
196    }
197}