Skip to main content

cachekit/
config.rs

1use std::time::Duration;
2
3use zeroize::Zeroizing;
4
5use crate::error::CachekitError;
6
7/// Maximum number of decrypt-only previous master keys.
8///
9/// Mirrors `cachekit_core::MAX_DECRYPT_ONLY_KEYS` (spec/encryption.md → "Key
10/// Rotation (Keyring)"), which is feature-gated behind `encryption` and so
11/// cannot be referenced here unconditionally. A drift-guard test in
12/// `config_tests.rs` asserts the two stay equal.
13pub const MAX_PREVIOUS_MASTER_KEYS: usize = 3;
14
15// ── CachekitConfig ────────────────────────────────────────────────────────────
16
17/// Configuration values for a CacheKit client.
18///
19/// A client reads a `CachekitConfig` on one path only: `CacheKit::from_env()`
20/// builds one with [`CachekitConfig::from_env`] and applies it. No `CacheKit`
21/// constructor takes a `CachekitConfig` value, so a config made with
22/// [`CachekitConfigBuilder`] configures no client.
23pub struct CachekitConfig {
24    /// API key for cachekit.io authentication.
25    pub api_key: Option<Zeroizing<String>>,
26    /// Base URL of the cachekit.io API.
27    pub api_url: String,
28    /// Master key used for zero-knowledge encryption (AES-256-GCM).
29    pub master_key: Option<Zeroizing<Vec<u8>>>,
30    /// Decrypt-only previous master keys for a rotation grace window, in
31    /// attempt order. At most [`MAX_PREVIOUS_MASTER_KEYS`] entries.
32    ///
33    /// These hold key material only. See
34    /// [`CachekitConfigBuilder::previous_master_keys`] for the paths that
35    /// apply previous keys to a client.
36    pub previous_master_keys: Vec<Zeroizing<Vec<u8>>>,
37    /// Default TTL for cache entries when none is specified at call site.
38    pub default_ttl: Duration,
39    /// Optional namespace prefix applied to all cache keys.
40    pub namespace: Option<String>,
41    /// Maximum number of entries in the L1 in-process cache.
42    pub l1_capacity: usize,
43    /// Maximum allowed payload size in bytes. Larger payloads are rejected.
44    pub max_payload_bytes: usize,
45}
46
47impl std::fmt::Debug for CachekitConfig {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        let api_key_repr = if self.api_key.is_some() {
50            "[REDACTED]"
51        } else {
52            "None"
53        };
54        let master_key_repr = if self.master_key.is_some() {
55            "[REDACTED]"
56        } else {
57            "None"
58        };
59
60        f.debug_struct("CachekitConfig")
61            .field("api_key", &api_key_repr)
62            .field("api_url", &self.api_url)
63            .field("master_key", &master_key_repr)
64            .field(
65                "previous_master_keys",
66                &format_args!("[REDACTED; {}]", self.previous_master_keys.len()),
67            )
68            .field("default_ttl", &self.default_ttl)
69            .field("namespace", &self.namespace)
70            .field("l1_capacity", &self.l1_capacity)
71            .field("max_payload_bytes", &self.max_payload_bytes)
72            .finish()
73    }
74}
75
76impl Default for CachekitConfig {
77    fn default() -> Self {
78        Self {
79            api_key: None,
80            api_url: "https://api.cachekit.io".to_owned(),
81            master_key: None,
82            previous_master_keys: Vec::new(),
83            default_ttl: Duration::from_secs(300),
84            namespace: None,
85            l1_capacity: 1000,
86            max_payload_bytes: 5 * 1024 * 1024, // 5 MiB
87        }
88    }
89}
90
91impl CachekitConfig {
92    /// Build configuration from environment variables.
93    ///
94    /// | Variable | Description |
95    /// |---|---|
96    /// | `CACHEKIT_API_KEY` | API key for cachekit.io |
97    /// | `CACHEKIT_API_URL` | Override API base URL (must be HTTPS) |
98    /// | `CACHEKIT_MASTER_KEY` | Hex-encoded master key (min 32 bytes) |
99    /// | `CACHEKIT_PREVIOUS_MASTER_KEYS` | Comma-separated hex-encoded decrypt-only previous master keys (max 3; blank value = unset) |
100    /// | `CACHEKIT_DEFAULT_TTL` | Default TTL in seconds (min 1) |
101    pub fn from_env() -> Result<Self, CachekitError> {
102        let mut config = Self::default();
103
104        // API key
105        if let Ok(val) = std::env::var("CACHEKIT_API_KEY") {
106            config.api_key = Some(Zeroizing::new(val));
107        }
108
109        // API URL — must be HTTPS
110        if let Ok(val) = std::env::var("CACHEKIT_API_URL") {
111            validate_https(&val)?;
112            config.api_url = val;
113        }
114
115        // Master key — hex-decode and validate length >= 32 bytes.
116        // Deliberately NO blank-value tolerance here (unlike the previous-keys
117        // var below): a blank CACHEKIT_MASTER_KEY treated as unset would
118        // silently turn encryption off.
119        if let Ok(val) = std::env::var("CACHEKIT_MASTER_KEY") {
120            // `env::var` hands back an owned copy of the hex secret. Wrap it so
121            // that copy is wiped on drop too — the decoded bytes below are
122            // already `Zeroizing`, but the hex form is the same key material.
123            // Defence in depth over the heap copy only: the process `environ`
124            // block still holds the identical hex for the process lifetime and
125            // is not wiped here, so this narrows post-lifetime recovery (core
126            // dumps, swap, heap reuse), it does not eliminate the exposure.
127            let val = Zeroizing::new(val);
128            config.master_key = Some(decode_master_key_hex(&val, "CACHEKIT_MASTER_KEY")?);
129        }
130
131        config.previous_master_keys =
132            previous_master_keys_from_env(config.master_key.as_deref().map(Vec::as_slice))?;
133
134        // Default TTL — minimum 1 second
135        if let Ok(val) = std::env::var("CACHEKIT_DEFAULT_TTL") {
136            let secs: u64 = val.parse().map_err(|e| {
137                CachekitError::Config(format!("CACHEKIT_DEFAULT_TTL must be an integer: {e}"))
138            })?;
139            if secs < 1 {
140                return Err(CachekitError::Config(
141                    "CACHEKIT_DEFAULT_TTL must be at least 1 second".to_owned(),
142                ));
143            }
144            config.default_ttl = Duration::from_secs(secs);
145        }
146
147        Ok(config)
148    }
149}
150
151// ── CachekitConfigBuilder ─────────────────────────────────────────────────────
152
153/// Fluent builder for a [`CachekitConfig`] value.
154///
155/// The builder validates and holds values. No `CacheKit` constructor takes
156/// the [`CachekitConfig`] it builds, so nothing set here (keys, namespace,
157/// TTL, capacity) reaches a client. To configure a client, use
158/// `CacheKit::builder()` or `CacheKit::from_env()`.
159#[derive(Default)]
160#[must_use]
161pub struct CachekitConfigBuilder {
162    inner: CachekitConfig,
163}
164
165impl CachekitConfigBuilder {
166    /// Create a new builder with defaults.
167    pub fn new() -> Self {
168        Self {
169            inner: CachekitConfig::default(),
170        }
171    }
172
173    /// Set the API key.
174    pub fn api_key(mut self, key: impl Into<String>) -> Self {
175        self.inner.api_key = Some(Zeroizing::new(key.into()));
176        self
177    }
178
179    /// Set the API base URL. Must use HTTPS.
180    pub fn api_url(mut self, url: impl Into<String>) -> Result<Self, CachekitError> {
181        let url = url.into();
182        validate_https(&url)?;
183        self.inner.api_url = url;
184        Ok(self)
185    }
186
187    /// Set the master key from a hex string. Must decode to at least 32 bytes.
188    pub fn master_key(mut self, hex_key: &str) -> Result<Self, CachekitError> {
189        let bytes = decode_master_key_hex(hex_key, "master_key")?;
190        validate_previous_master_keys(
191            Some(bytes.as_slice()),
192            &self.inner.previous_master_keys,
193            "previous_master_keys",
194        )?;
195        self.inner.master_key = Some(bytes);
196        Ok(self)
197    }
198
199    /// Set decrypt-only previous master keys from hex strings, in attempt
200    /// order, on the [`CachekitConfig`] value.
201    ///
202    /// No client reads keys set here. With the `encryption` feature enabled,
203    /// these paths apply previous keys to a client; each one decrypts with
204    /// the current key first, then each previous key in order:
205    ///
206    /// * `CacheKit::from_env()` and `CacheKit::secure_from_env()` read them
207    ///   from `CACHEKIT_PREVIOUS_MASTER_KEYS`.
208    /// * `CacheKitBuilder::encryption_from_bytes_with_previous()` takes them
209    ///   as arguments. It requires every key to be exactly 32 raw bytes,
210    ///   while this method accepts hex that decodes to 32 bytes or more.
211    ///
212    /// Without the `encryption` feature no path applies any master key:
213    /// `CacheKit::secure_from_env()` does not exist,
214    /// `CacheKit::from_env()` still succeeds but applies no key, and
215    /// `CacheKitBuilder::encryption_from_bytes_with_previous()` succeeds and
216    /// discards its keys.
217    ///
218    /// Validation is identical to [`Self::master_key`] per entry (valid hex,
219    /// at least 32 bytes). At most [`MAX_PREVIOUS_MASTER_KEYS`] entries —
220    /// more is a [`CachekitError::Config`], never truncated. The current
221    /// master key must not reappear here (forward-only rotation: a retired
222    /// key is never re-promoted).
223    ///
224    /// # Examples
225    ///
226    /// ```
227    /// use cachekit::config::CachekitConfigBuilder;
228    ///
229    /// // k2 is current after rotation; k1 is kept as a decrypt-only previous key.
230    /// let k1 = "11".repeat(32);
231    /// let k2 = "22".repeat(32);
232    ///
233    /// let config = CachekitConfigBuilder::new()
234    ///     .master_key(&k2)?
235    ///     .previous_master_keys(&[k1.as_str()])?
236    ///     .build();
237    ///
238    /// assert_eq!(config.previous_master_keys.len(), 1);
239    /// # Ok::<(), cachekit::CachekitError>(())
240    /// ```
241    pub fn previous_master_keys(mut self, hex_keys: &[&str]) -> Result<Self, CachekitError> {
242        let mut previous = Vec::with_capacity(hex_keys.len());
243        for hex_key in hex_keys {
244            previous.push(decode_master_key_hex(
245                hex_key,
246                "previous_master_keys entry",
247            )?);
248        }
249        validate_previous_master_keys(
250            self.inner.master_key.as_deref().map(Vec::as_slice),
251            &previous,
252            "previous_master_keys",
253        )?;
254        self.inner.previous_master_keys = previous;
255        Ok(self)
256    }
257
258    /// Set the default TTL. Must be at least 1 second.
259    pub fn default_ttl(mut self, ttl: Duration) -> Result<Self, CachekitError> {
260        if ttl < Duration::from_secs(1) {
261            return Err(CachekitError::Config(
262                "default_ttl must be at least 1 second".to_owned(),
263            ));
264        }
265        self.inner.default_ttl = ttl;
266        Ok(self)
267    }
268
269    /// Set the namespace prefix.
270    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
271        self.inner.namespace = Some(ns.into());
272        self
273    }
274
275    /// Set the L1 cache capacity (max entries).
276    pub fn l1_capacity(mut self, capacity: usize) -> Self {
277        self.inner.l1_capacity = capacity;
278        self
279    }
280
281    /// Finalise and return the [`CachekitConfig`].
282    pub fn build(self) -> CachekitConfig {
283        self.inner
284    }
285}
286
287// ── Helpers ───────────────────────────────────────────────────────────────────
288
289/// Hex-decode a master key and require at least 32 bytes. Shared by every
290/// master-key hex path — the current and previous keys here, the `secure`
291/// preset and the builder's `.encryption()` — so validation cannot drift.
292///
293/// Returns `Zeroizing` so the decoded key material is wiped on drop for its
294/// whole lifetime — including the early-drop paths where a caller's later
295/// validation step fails.
296pub(crate) fn decode_master_key_hex(
297    hex_key: &str,
298    what: &str,
299) -> Result<Zeroizing<Vec<u8>>, CachekitError> {
300    // Decode straight into zeroizing storage sized once: `hex::decode` grows
301    // its Vec by reallocation and frees each outgrown buffer unwiped.
302    let mut bytes = Zeroizing::new(vec![0u8; hex_key.len() / 2]);
303    hex::decode_to_slice(hex_key, &mut bytes).map_err(|e| {
304        // `FromHexError`'s Display quotes the offending character — one
305        // character of the key in an error string (CWE-532). Name the class.
306        // Exhaustive on purpose: a new variant must be reviewed for the same leak.
307        let reason = match e {
308            hex::FromHexError::InvalidHexCharacter { .. } => "contains a non-hex character",
309            hex::FromHexError::OddLength => "odd number of hex digits",
310            hex::FromHexError::InvalidStringLength => "invalid length",
311        };
312        CachekitError::Config(format!("{what} is not valid hex: {reason}"))
313    })?;
314    if bytes.len() < 32 {
315        return Err(CachekitError::Config(format!(
316            "{what} must be at least 32 bytes (64 hex chars); got {} bytes",
317            bytes.len()
318        )));
319    }
320    Ok(bytes)
321}
322
323/// `CACHEKIT_PREVIOUS_MASTER_KEYS`, decoded and validated against the
324/// decoded current key. The one reader of the variable, shared by
325/// [`CachekitConfig::from_env`] and `CacheKit::secure_from_env` so the two
326/// env paths cannot drift. Unset or blank yields no keys.
327pub(crate) fn previous_master_keys_from_env(
328    master_key: Option<&[u8]>,
329) -> Result<Vec<Zeroizing<Vec<u8>>>, CachekitError> {
330    // Same reasoning as CACHEKIT_MASTER_KEY in `from_env`: wipe the owned
331    // copy of the hex list on drop.
332    let val = match std::env::var("CACHEKIT_PREVIOUS_MASTER_KEYS") {
333        Ok(val) => Zeroizing::new(val),
334        Err(std::env::VarError::NotPresent) => return Ok(Vec::new()),
335        // Set but unreadable is not unset: dropping it would silently strip
336        // the decrypt-only keys, and every old-key read would fail closed.
337        Err(std::env::VarError::NotUnicode(_)) => {
338            return Err(CachekitError::Config(
339                "CACHEKIT_PREVIOUS_MASTER_KEYS is not valid UTF-8".to_owned(),
340            ))
341        }
342    };
343    // A wholly blank value retires the variable (the common way to disable
344    // it in shell profiles, Compose files, and k8s manifests) and is treated
345    // as unset; a blank entry inside a non-blank list is still an operator
346    // mistake.
347    if val.trim().is_empty() {
348        return Ok(Vec::new());
349    }
350    let mut previous = Vec::new();
351    for entry in val.split(',') {
352        let entry = entry.trim();
353        if entry.is_empty() {
354            return Err(CachekitError::Config(
355                "CACHEKIT_PREVIOUS_MASTER_KEYS contains an empty entry".to_owned(),
356            ));
357        }
358        previous.push(decode_master_key_hex(
359            entry,
360            "CACHEKIT_PREVIOUS_MASTER_KEYS entry",
361        )?);
362    }
363    // Previous keys without a current key is a broken rotation deploy:
364    // nothing would ever consume them, and the operator would only find out
365    // at the first secure_cache() call. Fail at load.
366    let Some(master_key) = master_key else {
367        return Err(CachekitError::Config(
368            "CACHEKIT_PREVIOUS_MASTER_KEYS requires CACHEKIT_MASTER_KEY to be set".to_owned(),
369        ));
370    };
371    validate_previous_master_keys(Some(master_key), &previous, "CACHEKIT_PREVIOUS_MASTER_KEYS")?;
372    Ok(previous)
373}
374
375/// Enforce the keyring config invariants: at most [`MAX_PREVIOUS_MASTER_KEYS`]
376/// previous keys (rejected, never truncated), and the current master key must
377/// not also appear in the previous list (the detectable subset of the
378/// forward-only rotation rule — re-promoting a retired key would resume a
379/// used, unknowable AES-GCM nonce budget).
380///
381/// Fail-fast mirror of the checks `cachekit_core::Keyring::new` repeats at
382/// client build time; plain equality is fine — both operands are
383/// operator-supplied configuration, not secrets under timing attack.
384fn validate_previous_master_keys(
385    master_key: Option<&[u8]>,
386    previous: &[Zeroizing<Vec<u8>>],
387    source: &str,
388) -> Result<(), CachekitError> {
389    if previous.len() > MAX_PREVIOUS_MASTER_KEYS {
390        return Err(CachekitError::Config(format!(
391            "{source} accepts at most {MAX_PREVIOUS_MASTER_KEYS} entries; got {}",
392            previous.len()
393        )));
394    }
395    if let Some(master) = master_key {
396        if previous.iter().any(|key| key.as_slice() == master) {
397            return Err(CachekitError::Config(format!(
398                "the current master key must not appear in {source} \
399                 (rotation is forward-only; retired keys are never re-promoted)"
400            )));
401        }
402    }
403    Ok(())
404}
405
406fn validate_https(url: &str) -> Result<(), CachekitError> {
407    if !url.starts_with("https://") {
408        return Err(CachekitError::Config(format!(
409            "API URL must use HTTPS; got: {url}"
410        )));
411    }
412    Ok(())
413}