Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
186 changes: 167 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,60 @@ 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()
)));
// `env::var` hands back an owned copy of the hex secret. Wrap it so
// that copy is wiped on drop too — the decoded bytes below 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.
let val = Zeroizing::new(val);
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") {
// Same reasoning as CACHEKIT_MASTER_KEY above: wipe the owned copy
Comment thread
27Bslash6 marked this conversation as resolved.
// of the hex list on drop.
let val = Zeroizing::new(val);
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 +211,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 +291,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