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}