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); set but empty, not hex or not UTF-8 is an error, never unset |
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. Set but not UTF-8 (a raw key put in
119 // the environment without hex-encoding it) is an error for the same
120 // reason, never unset.
121 match std::env::var("CACHEKIT_MASTER_KEY") {
122 Ok(val) => {
123 // `env::var` hands back an owned copy of the hex secret. Wrap it
124 // so that copy is wiped on drop too — the decoded bytes below are
125 // already `Zeroizing`, but the hex form is the same key material.
126 // Defence in depth over the heap copy only: the process `environ`
127 // block still holds the identical hex for the process lifetime
128 // and is not wiped here, so this narrows post-lifetime recovery
129 // (core dumps, swap, heap reuse), it does not eliminate the
130 // exposure.
131 let val = Zeroizing::new(val);
132 config.master_key = Some(decode_master_key_hex(&val, "CACHEKIT_MASTER_KEY")?);
133 }
134 Err(std::env::VarError::NotPresent) => {}
135 Err(std::env::VarError::NotUnicode(_)) => {
136 return Err(CachekitError::Config(
137 "CACHEKIT_MASTER_KEY is not valid UTF-8: set it to the key's 64 hex chars"
138 .to_owned(),
139 ))
140 }
141 }
142
143 config.previous_master_keys =
144 previous_master_keys_from_env(config.master_key.as_deref().map(Vec::as_slice))?;
145
146 // Default TTL — minimum 1 second
147 if let Ok(val) = std::env::var("CACHEKIT_DEFAULT_TTL") {
148 let secs: u64 = val.parse().map_err(|e| {
149 CachekitError::Config(format!("CACHEKIT_DEFAULT_TTL must be an integer: {e}"))
150 })?;
151 if secs < 1 {
152 return Err(CachekitError::Config(
153 "CACHEKIT_DEFAULT_TTL must be at least 1 second".to_owned(),
154 ));
155 }
156 config.default_ttl = Duration::from_secs(secs);
157 }
158
159 Ok(config)
160 }
161}
162
163// ── CachekitConfigBuilder ─────────────────────────────────────────────────────
164
165/// Fluent builder for a [`CachekitConfig`] value.
166///
167/// The builder validates and holds values. No `CacheKit` constructor takes
168/// the [`CachekitConfig`] it builds, so nothing set here (keys, namespace,
169/// TTL, capacity) reaches a client. To configure a client, use
170/// `CacheKit::builder()` or `CacheKit::from_env()`.
171#[derive(Default)]
172#[must_use]
173pub struct CachekitConfigBuilder {
174 inner: CachekitConfig,
175}
176
177impl CachekitConfigBuilder {
178 /// Create a new builder with defaults.
179 pub fn new() -> Self {
180 Self {
181 inner: CachekitConfig::default(),
182 }
183 }
184
185 /// Set the API key.
186 pub fn api_key(mut self, key: impl Into<String>) -> Self {
187 self.inner.api_key = Some(Zeroizing::new(key.into()));
188 self
189 }
190
191 /// Set the API base URL. Must use HTTPS.
192 pub fn api_url(mut self, url: impl Into<String>) -> Result<Self, CachekitError> {
193 let url = url.into();
194 validate_https(&url)?;
195 self.inner.api_url = url;
196 Ok(self)
197 }
198
199 /// Set the master key from a hex string. Must decode to at least 32 bytes.
200 pub fn master_key(mut self, hex_key: &str) -> Result<Self, CachekitError> {
201 let bytes = decode_master_key_hex(hex_key, "master_key")?;
202 validate_previous_master_keys(
203 Some(bytes.as_slice()),
204 &self.inner.previous_master_keys,
205 "previous_master_keys",
206 )?;
207 self.inner.master_key = Some(bytes);
208 Ok(self)
209 }
210
211 /// Set decrypt-only previous master keys from hex strings, in attempt
212 /// order, on the [`CachekitConfig`] value.
213 ///
214 /// No client reads keys set here. With the `encryption` feature enabled,
215 /// these paths apply previous keys to a client; each one decrypts with
216 /// the current key first, then each previous key in order:
217 ///
218 /// * `CacheKit::from_env()` and `CacheKit::secure_from_env()` read them
219 /// from `CACHEKIT_PREVIOUS_MASTER_KEYS`.
220 /// * `CacheKitBuilder::encryption_with_previous()` takes them as hex
221 /// arguments, validated as this method validates them, for any tenant.
222 /// * `CacheKitBuilder::encryption_from_bytes_with_previous()` takes them
223 /// as arguments. It requires every key to be exactly 32 raw bytes,
224 /// while this method accepts hex that decodes to 32 bytes or more.
225 ///
226 /// Without the `encryption` feature no path accepts a master key, so
227 /// none can silently drop one: `CacheKit::secure_from_env()` does not
228 /// exist, `CacheKit::from_env()` returns [`CachekitError::Config`] when
229 /// `CACHEKIT_MASTER_KEY` is set, and both builder methods always
230 /// return [`CachekitError::Config`].
231 ///
232 /// Validation is identical to [`Self::master_key`] per entry (valid hex,
233 /// at least 32 bytes). At most [`MAX_PREVIOUS_MASTER_KEYS`] entries —
234 /// more is a [`CachekitError::Config`], never truncated. The current
235 /// master key must not reappear here (forward-only rotation: a retired
236 /// key is never re-promoted).
237 ///
238 /// # Examples
239 ///
240 /// ```
241 /// use cachekit::config::CachekitConfigBuilder;
242 ///
243 /// // k2 is current after rotation; k1 is kept as a decrypt-only previous key.
244 /// let k1 = "11".repeat(32);
245 /// let k2 = "22".repeat(32);
246 ///
247 /// let config = CachekitConfigBuilder::new()
248 /// .master_key(&k2)?
249 /// .previous_master_keys(&[k1.as_str()])?
250 /// .build();
251 ///
252 /// assert_eq!(config.previous_master_keys.len(), 1);
253 /// # Ok::<(), cachekit::CachekitError>(())
254 /// ```
255 pub fn previous_master_keys(mut self, hex_keys: &[&str]) -> Result<Self, CachekitError> {
256 let mut previous = Vec::with_capacity(hex_keys.len());
257 for hex_key in hex_keys {
258 previous.push(decode_master_key_hex(
259 hex_key,
260 "previous_master_keys entry",
261 )?);
262 }
263 validate_previous_master_keys(
264 self.inner.master_key.as_deref().map(Vec::as_slice),
265 &previous,
266 "previous_master_keys",
267 )?;
268 self.inner.previous_master_keys = previous;
269 Ok(self)
270 }
271
272 /// Set the default TTL. Must be non-zero; a positive sub-second TTL is
273 /// sent to the backend as 1 second.
274 pub fn default_ttl(mut self, ttl: Duration) -> Result<Self, CachekitError> {
275 if ttl.is_zero() {
276 return Err(CachekitError::Config(
277 "default_ttl must be greater than zero".to_owned(),
278 ));
279 }
280 self.inner.default_ttl = ttl;
281 Ok(self)
282 }
283
284 /// Set the namespace prefix.
285 pub fn namespace(mut self, ns: impl Into<String>) -> Self {
286 self.inner.namespace = Some(ns.into());
287 self
288 }
289
290 /// Set the L1 cache capacity (max entries).
291 pub fn l1_capacity(mut self, capacity: usize) -> Self {
292 self.inner.l1_capacity = capacity;
293 self
294 }
295
296 /// Finalise and return the [`CachekitConfig`].
297 pub fn build(self) -> CachekitConfig {
298 self.inner
299 }
300}
301
302// ── Helpers ───────────────────────────────────────────────────────────────────
303
304/// Hex-decode a master key and require at least 32 bytes. Shared by every
305/// master-key hex path — the current and previous keys here, the `secure`
306/// preset and the builder's `.encryption()` — so validation cannot drift.
307///
308/// Returns `Zeroizing` so the decoded key material is wiped on drop for its
309/// whole lifetime — including the early-drop paths where a caller's later
310/// validation step fails.
311pub(crate) fn decode_master_key_hex(
312 hex_key: &str,
313 what: &str,
314) -> Result<Zeroizing<Vec<u8>>, CachekitError> {
315 // Decode straight into zeroizing storage sized once: `hex::decode` grows
316 // its Vec by reallocation and frees each outgrown buffer unwiped.
317 let mut bytes = Zeroizing::new(vec![0u8; hex_key.len() / 2]);
318 hex::decode_to_slice(hex_key, &mut bytes).map_err(|e| {
319 // `FromHexError`'s Display quotes the offending character — one
320 // character of the key in an error string (CWE-532). Name the class.
321 // Exhaustive on purpose: a new variant must be reviewed for the same leak.
322 let reason = match e {
323 hex::FromHexError::InvalidHexCharacter { .. } => "contains a non-hex character",
324 hex::FromHexError::OddLength => "odd number of hex digits",
325 hex::FromHexError::InvalidStringLength => "invalid length",
326 };
327 CachekitError::Config(format!("{what} is not valid hex: {reason}"))
328 })?;
329 if bytes.len() < 32 {
330 return Err(CachekitError::Config(format!(
331 "{what} must be at least 32 bytes (64 hex chars); got {} bytes",
332 bytes.len()
333 )));
334 }
335 Ok(bytes)
336}
337
338/// `CACHEKIT_PREVIOUS_MASTER_KEYS`, decoded and validated against the
339/// decoded current key. The one reader of the variable, shared by
340/// [`CachekitConfig::from_env`] and `CacheKit::secure_from_env` so the two
341/// env paths cannot drift. Unset or blank yields no keys.
342pub(crate) fn previous_master_keys_from_env(
343 master_key: Option<&[u8]>,
344) -> Result<Vec<Zeroizing<Vec<u8>>>, CachekitError> {
345 // Same reasoning as CACHEKIT_MASTER_KEY in `from_env`: wipe the owned
346 // copy of the hex list on drop.
347 let val = match std::env::var("CACHEKIT_PREVIOUS_MASTER_KEYS") {
348 Ok(val) => Zeroizing::new(val),
349 Err(std::env::VarError::NotPresent) => return Ok(Vec::new()),
350 // Set but unreadable is not unset: dropping it would silently strip
351 // the decrypt-only keys, and every old-key read would fail closed.
352 Err(std::env::VarError::NotUnicode(_)) => {
353 return Err(CachekitError::Config(
354 "CACHEKIT_PREVIOUS_MASTER_KEYS is not valid UTF-8".to_owned(),
355 ))
356 }
357 };
358 // A wholly blank value retires the variable (the common way to disable
359 // it in shell profiles, Compose files, and k8s manifests) and is treated
360 // as unset; a blank entry inside a non-blank list is still an operator
361 // mistake.
362 if val.trim().is_empty() {
363 return Ok(Vec::new());
364 }
365 let mut previous = Vec::new();
366 for entry in val.split(',') {
367 let entry = entry.trim();
368 if entry.is_empty() {
369 return Err(CachekitError::Config(
370 "CACHEKIT_PREVIOUS_MASTER_KEYS contains an empty entry".to_owned(),
371 ));
372 }
373 previous.push(decode_master_key_hex(
374 entry,
375 "CACHEKIT_PREVIOUS_MASTER_KEYS entry",
376 )?);
377 }
378 // Previous keys without a current key is a broken rotation deploy:
379 // nothing would ever consume them, and the operator would only find out
380 // at the first secure_cache() call. Fail at load.
381 let Some(master_key) = master_key else {
382 return Err(CachekitError::Config(
383 "CACHEKIT_PREVIOUS_MASTER_KEYS requires CACHEKIT_MASTER_KEY to be set".to_owned(),
384 ));
385 };
386 validate_previous_master_keys(Some(master_key), &previous, "CACHEKIT_PREVIOUS_MASTER_KEYS")?;
387 Ok(previous)
388}
389
390/// Enforce the keyring config invariants: at most [`MAX_PREVIOUS_MASTER_KEYS`]
391/// previous keys (rejected, never truncated), and the current master key must
392/// not also appear in the previous list (the detectable subset of the
393/// forward-only rotation rule — re-promoting a retired key would resume a
394/// used, unknowable AES-GCM nonce budget).
395///
396/// Fail-fast mirror of the checks `cachekit_core::Keyring::new` repeats at
397/// client build time; plain equality is fine — both operands are
398/// operator-supplied configuration, not secrets under timing attack.
399fn validate_previous_master_keys(
400 master_key: Option<&[u8]>,
401 previous: &[Zeroizing<Vec<u8>>],
402 source: &str,
403) -> Result<(), CachekitError> {
404 if previous.len() > MAX_PREVIOUS_MASTER_KEYS {
405 return Err(CachekitError::Config(format!(
406 "{source} accepts at most {MAX_PREVIOUS_MASTER_KEYS} entries; got {}",
407 previous.len()
408 )));
409 }
410 if let Some(master) = master_key {
411 if previous.iter().any(|key| key.as_slice() == master) {
412 return Err(CachekitError::Config(format!(
413 "the current master key must not appear in {source} \
414 (rotation is forward-only; retired keys are never re-promoted)"
415 )));
416 }
417 }
418 Ok(())
419}
420
421fn validate_https(url: &str) -> Result<(), CachekitError> {
422 if !url.starts_with("https://") {
423 return Err(CachekitError::Config(format!(
424 "API URL must use HTTPS; got: {url}"
425 )));
426 }
427 Ok(())
428}