cachekit-rs 0.13.1

Caching for Rust (beta). Supports cachekit.io SaaS, Redis, Memcached, local File, and Cloudflare Workers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
use std::time::Duration;

use zeroize::Zeroizing;

use crate::error::CachekitError;

/// Maximum number of decrypt-only previous master keys.
///
/// Mirrors `cachekit_core::MAX_DECRYPT_ONLY_KEYS` (spec/encryption.md → "Key
/// Rotation (Keyring)"), which is feature-gated behind `encryption` and so
/// cannot be referenced here unconditionally. A drift-guard test in
/// `config_tests.rs` asserts the two stay equal.
pub const MAX_PREVIOUS_MASTER_KEYS: usize = 3;

// ── CachekitConfig ────────────────────────────────────────────────────────────

/// Configuration values for a CacheKit client.
///
/// A client reads a `CachekitConfig` on one path only: `CacheKit::from_env()`
/// builds one with [`CachekitConfig::from_env`] and applies it, except the
/// key fields: only an explicit encryption call such as
/// `CacheKitBuilder::encryption_from_env()` encrypts. No `CacheKit`
/// constructor takes a `CachekitConfig` value, so a config made with
/// [`CachekitConfigBuilder`] configures no client.
pub struct CachekitConfig {
    /// API key for cachekit.io authentication.
    pub api_key: Option<Zeroizing<String>>,
    /// Base URL of the cachekit.io API.
    pub api_url: String,
    /// Master key for zero-knowledge encryption (AES-256-GCM). Key material
    /// only: no client encrypts because this is set.
    pub master_key: Option<Zeroizing<Vec<u8>>>,
    /// Decrypt-only previous master keys for a rotation grace window, in
    /// attempt order. At most [`MAX_PREVIOUS_MASTER_KEYS`] entries.
    ///
    /// These hold key material only. See
    /// [`CachekitConfigBuilder::previous_master_keys`] for the paths that
    /// apply previous keys to a client.
    pub previous_master_keys: Vec<Zeroizing<Vec<u8>>>,
    /// Default TTL for cache entries when none is specified at call site.
    pub default_ttl: Duration,
    /// Optional namespace prefix applied to all cache keys.
    pub namespace: Option<String>,
    /// Maximum number of entries in the L1 in-process cache.
    pub l1_capacity: usize,
    /// Maximum allowed payload size in bytes. Larger payloads are rejected.
    pub max_payload_bytes: usize,
}

impl std::fmt::Debug for CachekitConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let api_key_repr = if self.api_key.is_some() {
            "[REDACTED]"
        } else {
            "None"
        };
        let master_key_repr = if self.master_key.is_some() {
            "[REDACTED]"
        } else {
            "None"
        };

        f.debug_struct("CachekitConfig")
            .field("api_key", &api_key_repr)
            .field("api_url", &self.api_url)
            .field("master_key", &master_key_repr)
            .field(
                "previous_master_keys",
                &format_args!("[REDACTED; {}]", self.previous_master_keys.len()),
            )
            .field("default_ttl", &self.default_ttl)
            .field("namespace", &self.namespace)
            .field("l1_capacity", &self.l1_capacity)
            .field("max_payload_bytes", &self.max_payload_bytes)
            .finish()
    }
}

impl Default for CachekitConfig {
    fn default() -> Self {
        Self {
            api_key: None,
            api_url: "https://api.cachekit.io".to_owned(),
            master_key: None,
            previous_master_keys: Vec::new(),
            default_ttl: Duration::from_secs(300),
            namespace: None,
            l1_capacity: 1000,
            max_payload_bytes: 5 * 1024 * 1024, // 5 MiB
        }
    }
}

impl CachekitConfig {
    /// Build configuration from environment variables.
    ///
    /// | Variable | Description |
    /// |---|---|
    /// | `CACHEKIT_API_KEY` | API key for cachekit.io |
    /// | `CACHEKIT_API_URL` | Override API base URL (must be HTTPS) |
    /// | `CACHEKIT_MASTER_KEY` | Hex-encoded master key (min 32 bytes); set but empty, not hex or not UTF-8 is an error, never unset |
    /// | `CACHEKIT_PREVIOUS_MASTER_KEYS` | Comma-separated hex-encoded decrypt-only previous master keys (max 3; blank value = unset) |
    pub fn from_env() -> Result<Self, CachekitError> {
        let mut config = Self::default();

        // API key
        if let Ok(val) = std::env::var("CACHEKIT_API_KEY") {
            config.api_key = Some(Zeroizing::new(val));
        }

        // API URL — must be HTTPS
        if let Ok(val) = std::env::var("CACHEKIT_API_URL") {
            validate_https(&val)?;
            config.api_url = val;
        }

        config.master_key = master_key_from_env()?;
        config.previous_master_keys =
            previous_master_keys_from_env(config.master_key.as_deref().map(Vec::as_slice))?;

        Ok(config)
    }
}

// ── CachekitConfigBuilder ─────────────────────────────────────────────────────

/// Fluent builder for a [`CachekitConfig`] value.
///
/// The builder validates and holds values. No `CacheKit` constructor takes
/// the [`CachekitConfig`] it builds, so nothing set here (keys, namespace,
/// TTL, capacity) reaches a client. To configure a client, use
/// `CacheKit::builder()` or `CacheKit::from_env()`.
#[derive(Default)]
#[must_use]
pub struct CachekitConfigBuilder {
    inner: CachekitConfig,
}

impl CachekitConfigBuilder {
    /// Create a new builder with defaults.
    pub fn new() -> Self {
        Self {
            inner: CachekitConfig::default(),
        }
    }

    /// Set the API key.
    pub fn api_key(mut self, key: impl Into<String>) -> Self {
        self.inner.api_key = Some(Zeroizing::new(key.into()));
        self
    }

    /// Set the API base URL. Must use HTTPS.
    pub fn api_url(mut self, url: impl Into<String>) -> Result<Self, CachekitError> {
        let url = url.into();
        validate_https(&url)?;
        self.inner.api_url = url;
        Ok(self)
    }

    /// Set the master key from a hex string. Must decode to at least 32 bytes.
    pub fn master_key(mut self, hex_key: &str) -> Result<Self, CachekitError> {
        let bytes = decode_master_key_hex(hex_key, "master_key")?;
        validate_previous_master_keys(
            Some(bytes.as_slice()),
            &self.inner.previous_master_keys,
            "previous_master_keys",
        )?;
        self.inner.master_key = Some(bytes);
        Ok(self)
    }

    /// Set decrypt-only previous master keys from hex strings, in attempt
    /// order, on the [`CachekitConfig`] value.
    ///
    /// No client reads keys set here. With the `encryption` feature enabled,
    /// these paths apply previous keys to a client; each one decrypts with
    /// the current key first, then each previous key in order:
    ///
    /// * `CacheKitBuilder::encryption_from_env()` and
    ///   `CacheKit::secure_from_env()` read them from
    ///   `CACHEKIT_PREVIOUS_MASTER_KEYS`.
    /// * `CacheKitBuilder::encryption_with_previous()` takes them as hex
    ///   arguments, validated as this method validates them, for any tenant.
    /// * `CacheKitBuilder::encryption_from_bytes_with_previous()` takes them
    ///   as arguments. It requires every key to be exactly 32 raw bytes,
    ///   while this method accepts hex that decodes to 32 bytes or more.
    ///
    /// Without the `encryption` feature no call that asks to encrypt accepts
    /// a key: `CacheKit::secure_from_env()` does not exist, and all three
    /// builder methods always return [`CachekitError::Config`].
    /// `CacheKit::from_env()` validates the variables and applies them on no
    /// build.
    ///
    /// Validation is identical to [`Self::master_key`] per entry (valid hex,
    /// at least 32 bytes). At most [`MAX_PREVIOUS_MASTER_KEYS`] entries —
    /// more is a [`CachekitError::Config`], never truncated. The current
    /// master key must not reappear here (forward-only rotation: a retired
    /// key is never re-promoted).
    ///
    /// # Examples
    ///
    /// ```
    /// use cachekit::config::CachekitConfigBuilder;
    ///
    /// // k2 is current after rotation; k1 is kept as a decrypt-only previous key.
    /// let k1 = "11".repeat(32);
    /// let k2 = "22".repeat(32);
    ///
    /// let config = CachekitConfigBuilder::new()
    ///     .master_key(&k2)?
    ///     .previous_master_keys(&[k1.as_str()])?
    ///     .build();
    ///
    /// assert_eq!(config.previous_master_keys.len(), 1);
    /// # Ok::<(), cachekit::CachekitError>(())
    /// ```
    pub fn previous_master_keys(mut self, hex_keys: &[&str]) -> Result<Self, CachekitError> {
        let mut previous = Vec::with_capacity(hex_keys.len());
        for hex_key in hex_keys {
            previous.push(decode_master_key_hex(
                hex_key,
                "previous_master_keys entry",
            )?);
        }
        validate_previous_master_keys(
            self.inner.master_key.as_deref().map(Vec::as_slice),
            &previous,
            "previous_master_keys",
        )?;
        self.inner.previous_master_keys = previous;
        Ok(self)
    }

    /// Set the default TTL. Must be non-zero; a positive sub-second TTL is
    /// sent to the backend as 1 second.
    pub fn default_ttl(mut self, ttl: Duration) -> Result<Self, CachekitError> {
        if ttl.is_zero() {
            return Err(CachekitError::Config(
                "default_ttl must be greater than zero".to_owned(),
            ));
        }
        self.inner.default_ttl = ttl;
        Ok(self)
    }

    /// Set the namespace prefix.
    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
        self.inner.namespace = Some(ns.into());
        self
    }

    /// Set the L1 cache capacity (max entries).
    pub fn l1_capacity(mut self, capacity: usize) -> Self {
        self.inner.l1_capacity = capacity;
        self
    }

    /// Finalise and return the [`CachekitConfig`].
    pub fn build(self) -> CachekitConfig {
        self.inner
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Hex-decode a master key and require at least 32 bytes. Shared by every
/// master-key hex path — the current and previous keys here, the `secure`
/// preset and the builder's `.encryption()` — so validation cannot drift.
///
/// Returns `Zeroizing` so the decoded key material is wiped on drop for its
/// whole lifetime — including the early-drop paths where a caller's later
/// validation step fails.
pub(crate) fn decode_master_key_hex(
    hex_key: &str,
    what: &str,
) -> Result<Zeroizing<Vec<u8>>, CachekitError> {
    // Decode straight into zeroizing storage sized once: `hex::decode` grows
    // its Vec by reallocation and frees each outgrown buffer unwiped.
    let mut bytes = Zeroizing::new(vec![0u8; hex_key.len() / 2]);
    hex::decode_to_slice(hex_key, &mut bytes).map_err(|e| {
        // `FromHexError`'s Display quotes the offending character — one
        // character of the key in an error string (CWE-532). Name the class.
        // Exhaustive on purpose: a new variant must be reviewed for the same leak.
        let reason = match e {
            hex::FromHexError::InvalidHexCharacter { .. } => "contains a non-hex character",
            hex::FromHexError::OddLength => "odd number of hex digits",
            hex::FromHexError::InvalidStringLength => "invalid length",
        };
        CachekitError::Config(format!("{what} is not valid hex: {reason}"))
    })?;
    if bytes.len() < 32 {
        return Err(CachekitError::Config(format!(
            "{what} must be at least 32 bytes (64 hex chars); got {} bytes",
            bytes.len()
        )));
    }
    Ok(bytes)
}

/// `CACHEKIT_MASTER_KEY` as set, not yet decoded, or `None` when unset. The
/// one reader of the variable, shared by [`CachekitConfig::from_env`] and
/// [`master_keys_from_env`] so the env paths cannot drift. Set but not UTF-8
/// (a raw key put in the environment without hex-encoding it) is an error,
/// never unset.
fn master_key_hex_from_env() -> Result<Option<Zeroizing<String>>, CachekitError> {
    match std::env::var("CACHEKIT_MASTER_KEY") {
        // `env::var` hands back an owned copy of the hex secret. Wrap it so
        // that copy is wiped on drop too — the decoded bytes are already
        // `Zeroizing`, but the hex form is the same key material. Defence in
        // depth over the heap copy only: the process `environ` block still
        // holds the identical hex for the process lifetime and is not wiped
        // here, so this narrows post-lifetime recovery (core dumps, swap,
        // heap reuse), it does not eliminate the exposure.
        Ok(val) => Ok(Some(Zeroizing::new(val))),
        Err(std::env::VarError::NotPresent) => Ok(None),
        Err(std::env::VarError::NotUnicode(raw)) => {
            wipe(raw);
            Err(CachekitError::Config(
                "CACHEKIT_MASTER_KEY is not valid UTF-8: set it to the key's 64 hex chars"
                    .to_owned(),
            ))
        }
    }
}

/// Wipe the owned copy `env::var` hands back with `VarError::NotUnicode`:
/// a non-UTF-8 key variable most likely holds a raw key exported without
/// hex-encoding, so the copy is key material. `into_encoded_bytes` takes the
/// buffer without copying it.
fn wipe(raw: std::ffi::OsString) {
    drop(Zeroizing::new(raw.into_encoded_bytes()));
}

/// `CACHEKIT_MASTER_KEY`, hex-decoded, or `None` when unset.
///
/// Deliberately NO blank-value tolerance (unlike
/// `CACHEKIT_PREVIOUS_MASTER_KEYS`): a blank key is a broken deploy of a key,
/// not an unset one.
fn master_key_from_env() -> Result<Option<Zeroizing<Vec<u8>>>, CachekitError> {
    let Some(val) = master_key_hex_from_env()? else {
        return Ok(None);
    };
    if val.is_empty() {
        return Err(CachekitError::Config(
            "CACHEKIT_MASTER_KEY is set but empty: set it to a 64-hex-char key".to_owned(),
        ));
    }
    decode_master_key_hex(&val, "CACHEKIT_MASTER_KEY").map(Some)
}

/// A decoded current master key and its decrypt-only previous keys.
#[cfg(feature = "encryption")]
pub(crate) type EnvKeys = (Zeroizing<Vec<u8>>, Vec<Zeroizing<Vec<u8>>>);

/// `CACHEKIT_MASTER_KEY` and `CACHEKIT_PREVIOUS_MASTER_KEYS` for the explicit
/// env-sourced encryption calls, `CacheKit::secure_from_env` and
/// `CacheKitBuilder::encryption_from_env`. Each was asked to encrypt, so an
/// unset or empty key is an error, never plaintext; `explicit_key_call`
/// names the call that takes the key as an argument instead. Returns the
/// current key and the previous keys.
#[cfg(feature = "encryption")]
pub(crate) fn master_keys_from_env(explicit_key_call: &str) -> Result<EnvKeys, CachekitError> {
    let hex_key = master_key_hex_from_env()?
        .filter(|key| !key.is_empty())
        .ok_or_else(|| {
            CachekitError::Config(format!(
                "CACHEKIT_MASTER_KEY is unset or empty: set it to a 64-hex-char key or pass \
                 the key to {explicit_key_call}"
            ))
        })?;
    let master_key = decode_master_key_hex(&hex_key, "CACHEKIT_MASTER_KEY")?;
    let previous = previous_master_keys_from_env(Some(&master_key))?;
    Ok((master_key, previous))
}

/// `CACHEKIT_PREVIOUS_MASTER_KEYS`, decoded and validated against the
/// decoded current key. The one reader of the variable, shared by
/// [`CachekitConfig::from_env`] and [`master_keys_from_env`] so the env
/// paths cannot drift. Unset or blank yields no keys.
pub(crate) fn previous_master_keys_from_env(
    master_key: Option<&[u8]>,
) -> Result<Vec<Zeroizing<Vec<u8>>>, CachekitError> {
    // Same reasoning as CACHEKIT_MASTER_KEY in `from_env`: wipe the owned
    // copy of the hex list on drop.
    let val = match std::env::var("CACHEKIT_PREVIOUS_MASTER_KEYS") {
        Ok(val) => Zeroizing::new(val),
        Err(std::env::VarError::NotPresent) => return Ok(Vec::new()),
        // Set but unreadable is not unset: dropping it would silently strip
        // the decrypt-only keys, and every old-key read would fail closed.
        Err(std::env::VarError::NotUnicode(raw)) => {
            wipe(raw);
            return Err(CachekitError::Config(
                "CACHEKIT_PREVIOUS_MASTER_KEYS is not valid UTF-8".to_owned(),
            ));
        }
    };
    // A wholly blank value retires the variable (the common way to disable
    // it in shell profiles, Compose files, and k8s manifests) and is treated
    // as unset; a blank entry inside a non-blank list is still an operator
    // mistake.
    if val.trim().is_empty() {
        return Ok(Vec::new());
    }
    let mut previous = Vec::new();
    for entry in val.split(',') {
        let entry = entry.trim();
        if entry.is_empty() {
            return Err(CachekitError::Config(
                "CACHEKIT_PREVIOUS_MASTER_KEYS contains an empty entry".to_owned(),
            ));
        }
        previous.push(decode_master_key_hex(
            entry,
            "CACHEKIT_PREVIOUS_MASTER_KEYS entry",
        )?);
    }
    // Previous keys without a current key is a broken rotation deploy:
    // nothing would ever consume them, and the operator would only find out
    // at the first secure_cache() call. Fail at load.
    let Some(master_key) = master_key else {
        return Err(CachekitError::Config(
            "CACHEKIT_PREVIOUS_MASTER_KEYS requires CACHEKIT_MASTER_KEY to be set".to_owned(),
        ));
    };
    validate_previous_master_keys(Some(master_key), &previous, "CACHEKIT_PREVIOUS_MASTER_KEYS")?;
    Ok(previous)
}

/// Enforce the keyring config invariants: at most [`MAX_PREVIOUS_MASTER_KEYS`]
/// previous keys (rejected, never truncated), and the current master key must
/// not also appear in the previous list (the detectable subset of the
/// forward-only rotation rule — re-promoting a retired key would resume a
/// used, unknowable AES-GCM nonce budget).
///
/// Fail-fast mirror of the checks `cachekit_core::Keyring::new` repeats at
/// client build time; plain equality is fine — both operands are
/// operator-supplied configuration, not secrets under timing attack.
fn validate_previous_master_keys(
    master_key: Option<&[u8]>,
    previous: &[Zeroizing<Vec<u8>>],
    source: &str,
) -> Result<(), CachekitError> {
    if previous.len() > MAX_PREVIOUS_MASTER_KEYS {
        return Err(CachekitError::Config(format!(
            "{source} accepts at most {MAX_PREVIOUS_MASTER_KEYS} entries; got {}",
            previous.len()
        )));
    }
    if let Some(master) = master_key {
        if previous.iter().any(|key| key.as_slice() == master) {
            return Err(CachekitError::Config(format!(
                "the current master key must not appear in {source} \
                 (rotation is forward-only; retired keys are never re-promoted)"
            )));
        }
    }
    Ok(())
}

fn validate_https(url: &str) -> Result<(), CachekitError> {
    if !url.starts_with("https://") {
        return Err(CachekitError::Config(format!(
            "API URL must use HTTPS; got: {url}"
        )));
    }
    Ok(())
}