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