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