Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,23 @@ Cross-SDK compatible — ciphertext produced by the Python SDK decrypts with the

</details>

### Key Rotation

Rotate the master key without invalidating existing entries: promote the new key to current and keep the old one as a decrypt-only previous key during a grace window (max 3, per the [protocol keyring spec](https://github.com/cachekit-io/protocol/blob/main/spec/encryption.md)). Writes always use the current key; reads attempt the current key first, then each previous key in order. Old entries age out via TTL or re-encrypt on the next write — no bulk re-encryption.

```rust
// Env: CACHEKIT_MASTER_KEY=<k2-hex> CACHEKIT_PREVIOUS_MASTER_KEYS=<k1-hex>
let cache = CacheKit::from_env()?.build()?;

// Or explicitly on the client builder:
let cache = CacheKit::builder()
.backend(backend)
.encryption_from_bytes_with_previous(&k2_bytes, &[&k1_bytes], "tenant")?
.build()?;
```

Rotation is forward-only: a retired key is never re-promoted (re-promoting would resume a used AES-GCM nonce budget), and a config listing the current key among the previous keys is rejected at load.

---

## Cross-SDK Interop Mode
Expand Down Expand Up @@ -433,6 +450,7 @@ Requires a tokio runtime for backoff timers (the `redis` and `cachekitio` backen
| `CACHEKIT_API_KEY` | ✅ | API key for cachekit.io |
| `CACHEKIT_API_URL` | ❌ | Override API endpoint (default: `https://api.cachekit.io`) |
| `CACHEKIT_MASTER_KEY` | ❌ | Hex-encoded master key (min 32 bytes) for encryption |
| `CACHEKIT_PREVIOUS_MASTER_KEYS` | ❌ | Comma-separated hex-encoded decrypt-only previous master keys for key rotation (max 3; a blank value is treated as unset) |
| `CACHEKIT_DEFAULT_TTL` | ❌ | Default TTL in seconds (min 1, default: 300) |

> [!CAUTION]
Expand Down
2 changes: 1 addition & 1 deletion crates/cachekit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ reliability = ["tokio/time"]
unsync = []

[dependencies]
cachekit-core = { version = "0.4", features = ["messagepack"] }
cachekit-core = { version = "0.5", features = ["messagepack"] }
Comment thread
27Bslash6 marked this conversation as resolved.
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"
thiserror = "2.0"
Expand Down
45 changes: 43 additions & 2 deletions crates/cachekit/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,13 @@ impl CacheKit {
#[cfg(feature = "encryption")]
if let Some(ref master_key) = config.master_key {
let namespace = config.namespace.as_deref().unwrap_or("default");
builder = builder.encryption_from_bytes(master_key, namespace)?;
let previous: Vec<&[u8]> = config
.previous_master_keys
.iter()
.map(|key| key.as_slice())
.collect();
builder =
builder.encryption_from_bytes_with_previous(master_key, &previous, namespace)?;
}

Ok(builder)
Expand Down Expand Up @@ -991,7 +997,7 @@ impl CacheKitBuilder {

/// Configure encryption from raw master key bytes and tenant ID.
///
/// The master key must be at least 16 bytes (32 recommended).
/// The master key must be at least 32 bytes.
/// Keys are derived per-tenant via HKDF-SHA256.
#[cfg(feature = "encryption")]
pub fn encryption_from_bytes(
Expand All @@ -1004,6 +1010,31 @@ impl CacheKitBuilder {
Ok(self)
}

/// Configure encryption with decrypt-only previous master keys for
/// key rotation.
///
/// Writes encrypt under `master_key`; reads attempt it first, then each
/// key in `previous_keys` sequentially (attempt order = slice order).
/// At most 3 previous keys; supplying more is a config error, never
/// truncated. See [`crate::encryption::EncryptionLayer::with_previous_keys`].
Comment thread
coderabbitai[bot] marked this conversation as resolved.
///
/// Every key, current and previous, must be at least 32 bytes.
#[cfg(feature = "encryption")]
pub fn encryption_from_bytes_with_previous(
mut self,
master_key: &[u8],
previous_keys: &[&[u8]],
tenant_id: &str,
) -> Result<Self, CachekitError> {
let layer = crate::encryption::EncryptionLayer::with_previous_keys(
master_key,
previous_keys,
tenant_id,
)?;
self.encryption = Some(SharedEncryption::new(layer));
Ok(self)
}

/// Configure encryption from a hex-encoded master key string.
///
/// Convenience wrapper that hex-decodes then delegates to
Expand All @@ -1025,6 +1056,16 @@ impl CacheKitBuilder {
Ok(self)
}

#[cfg(not(feature = "encryption"))]
pub fn encryption_from_bytes_with_previous(
self,
_master_key: &[u8],
_previous_keys: &[&[u8]],
_tenant_id: &str,
) -> Result<Self, CachekitError> {
Ok(self)
}

#[cfg(not(feature = "encryption"))]
pub fn encryption(self, _hex_key: &str, _tenant_id: &str) -> Result<Self, CachekitError> {
Ok(self)
Expand Down
175 changes: 156 additions & 19 deletions crates/cachekit/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ 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 ────────────────────────────────────────────────────────────

/// Runtime configuration for a [`crate::client::CacheKit`] instance.
Expand All @@ -14,6 +22,11 @@ pub struct CachekitConfig {
pub api_url: String,
/// Master key used for zero-knowledge encryption (AES-256-GCM).
pub master_key: Option<Zeroizing<Vec<u8>>>,
/// Decrypt-only previous master keys retained during a rotation grace
/// window, in attempt order. Writes always use `master_key`; reads
/// attempt it first, then these, sequentially. At most
/// [`MAX_PREVIOUS_MASTER_KEYS`] entries.
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.
Expand Down Expand Up @@ -41,6 +54,10 @@ impl std::fmt::Debug for 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)
Expand All @@ -55,6 +72,7 @@ impl Default for CachekitConfig {
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,
Expand All @@ -71,6 +89,7 @@ impl CachekitConfig {
/// | `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) |
/// | `CACHEKIT_PREVIOUS_MASTER_KEYS` | Comma-separated hex-encoded decrypt-only previous master keys (max 3; blank value = unset) |
/// | `CACHEKIT_DEFAULT_TTL` | Default TTL in seconds (min 1) |
pub fn from_env() -> Result<Self, CachekitError> {
let mut config = Self::default();
Expand All @@ -86,19 +105,49 @@ impl CachekitConfig {
config.api_url = val;
}

// Master key — hex-decode and validate length >= 32 bytes
// Master key — hex-decode and validate length >= 32 bytes.
// Deliberately NO blank-value tolerance here (unlike the previous-keys
// var below): a blank CACHEKIT_MASTER_KEY treated as unset would
// silently turn encryption off.
if let Ok(val) = std::env::var("CACHEKIT_MASTER_KEY") {
let bytes = hex::decode(&val).map_err(|e| {
CachekitError::Config(format!("CACHEKIT_MASTER_KEY is not valid hex: {e}"))
})?;
if bytes.len() < 32 {
return Err(CachekitError::Config(format!(
"CACHEKIT_MASTER_KEY must be at least 32 bytes ({} hex chars); got {} bytes",
64,
bytes.len()
)));
config.master_key = Some(decode_master_key_hex(&val, "CACHEKIT_MASTER_KEY")?);
}

// Previous master keys — comma-separated hex, decrypt-only, max 3.
Comment thread
kodus-27b[bot] marked this conversation as resolved.
// 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 let Ok(val) = std::env::var("CACHEKIT_PREVIOUS_MASTER_KEYS") {
if !val.trim().is_empty() {
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() call. Fail at load.
if config.master_key.is_none() {
return Err(CachekitError::Config(
"CACHEKIT_PREVIOUS_MASTER_KEYS requires CACHEKIT_MASTER_KEY to be set"
.to_owned(),
));
}
validate_previous_master_keys(
config.master_key.as_deref().map(Vec::as_slice),
&previous,
)?;
config.previous_master_keys = previous;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
config.master_key = Some(Zeroizing::new(bytes));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Default TTL — minimum 1 second
Expand Down Expand Up @@ -151,15 +200,52 @@ impl CachekitConfigBuilder {

/// 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 = hex::decode(hex_key)
.map_err(|e| CachekitError::Config(format!("master_key is not valid hex: {e}")))?;
if bytes.len() < 32 {
return Err(CachekitError::Config(format!(
"master_key must be at least 32 bytes; got {}",
bytes.len()
)));
let bytes = decode_master_key_hex(hex_key, "master_key")?;
validate_previous_master_keys(Some(bytes.as_slice()), &self.inner.previous_master_keys)?;
self.inner.master_key = Some(bytes);
Ok(self)
}

/// Set decrypt-only previous master keys from hex strings, in attempt
/// order. Retained during a key-rotation grace window: reads attempt the
/// current master key first, then each of these sequentially.
///
/// 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 stays readable during the grace window.
/// 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",
)?);
}
self.inner.master_key = Some(Zeroizing::new(bytes));
validate_previous_master_keys(
self.inner.master_key.as_deref().map(Vec::as_slice),
&previous,
)?;
self.inner.previous_master_keys = previous;
Ok(self)
}

Expand Down Expand Up @@ -194,6 +280,57 @@ impl CachekitConfigBuilder {

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

/// Hex-decode a master key and require at least 32 bytes. Shared by the
/// current-key and previous-key paths 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.
fn decode_master_key_hex(hex_key: &str, what: &str) -> Result<Zeroizing<Vec<u8>>, CachekitError> {
let bytes = Zeroizing::new(
hex::decode(hex_key)
.map_err(|e| CachekitError::Config(format!("{what} is not valid hex: {e}")))?,
);
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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// 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>>],
) -> Result<(), CachekitError> {
if previous.len() > MAX_PREVIOUS_MASTER_KEYS {
return Err(CachekitError::Config(format!(
"previous_master_keys 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(
"the current master key must not appear in previous_master_keys \
(rotation is forward-only; retired keys are never re-promoted)"
.to_owned(),
));
}
}
Ok(())
}

fn validate_https(url: &str) -> Result<(), CachekitError> {
if !url.starts_with("https://") {
return Err(CachekitError::Config(format!(
Expand Down
Loading