mod common;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::common::MockBackend;
use cachekit::client::SharedBackend;
use cachekit::{CacheKit, CachekitError};
const TEST_MASTER_KEY: &[u8] = b"test_master_key_32_bytes_long!!!";
fn test_master_key_hex() -> String {
hex::encode(TEST_MASTER_KEY)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Secret {
api_key: String,
user_id: u64,
}
fn make_encrypted_client(backend: SharedBackend) -> CacheKit {
CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds")
}
fn make_encrypted_client_with_l1(backend: SharedBackend) -> CacheKit {
CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.l1_capacity(100)
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds")
}
#[tokio::test]
async fn secure_set_and_get() {
let backend = MockBackend::shared();
let client = make_encrypted_client(backend);
let secret = Secret {
api_key: "sk-live-abc123".to_owned(), user_id: 42,
};
let secure = client
.secure_cache()
.expect("secure_cache() should work with encryption configured");
secure.set("secret:42", &secret).await.expect("secure set");
let retrieved: Secret = secure
.get("secret:42")
.await
.expect("secure get")
.expect("value should exist");
assert_eq!(retrieved, secret);
}
#[tokio::test]
async fn secure_data_is_encrypted_in_backend() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client(shared);
let secret = Secret {
api_key: "sk-live-SUPERSECRET".to_owned(), user_id: 999,
};
let secure = client.secure_cache().unwrap();
secure.set("secret:999", &secret).await.unwrap();
let raw_bytes = backend
.store
.lock()
.await
.get("secret:999")
.cloned()
.expect("key should exist in backend");
let raw_str = String::from_utf8_lossy(&raw_bytes);
assert!(
!raw_str.contains("SUPERSECRET"),
"backend must store ciphertext, not plaintext; got: {raw_str}"
);
assert!(
raw_bytes.len() >= 28,
"ciphertext too short: {} bytes (expected nonce + tag overhead)",
raw_bytes.len()
);
}
#[tokio::test]
async fn secure_without_master_key_fails() {
let client = CacheKit::builder()
.backend(MockBackend::shared())
.no_l1()
.build()
.expect("client builds without encryption");
let result = client.secure_cache();
assert!(
result.is_err(),
"secure_cache() without encryption should fail"
);
let err = result.unwrap_err();
assert!(
matches!(err, CachekitError::Config(_)),
"expected Config error, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("CacheKit::secure(url, master_key_hex)")
&& msg.contains("CacheKit::secure_from_env(url)")
&& msg.contains("CACHEKIT_MASTER_KEY"),
"error should name both secure preset forms and CACHEKIT_MASTER_KEY: {msg}"
);
}
#[tokio::test]
async fn secure_get_missing_returns_none() {
let client = make_encrypted_client(MockBackend::shared());
let secure = client.secure_cache().unwrap();
let result: Option<String> = secure.get("nonexistent").await.expect("get should succeed");
assert!(result.is_none());
}
#[tokio::test]
async fn secure_delete() {
let client = make_encrypted_client(MockBackend::shared());
let secure = client.secure_cache().unwrap();
secure.set("to-delete", &"temporary").await.unwrap();
assert!(secure.exists("to-delete").await.unwrap());
let deleted = secure.delete("to-delete").await.unwrap();
assert!(deleted);
let gone: Option<String> = secure.get("to-delete").await.unwrap();
assert!(gone.is_none());
}
#[tokio::test]
async fn secure_wrong_key_fails_decryption() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client(shared);
let secure = client.secure_cache().unwrap();
secure.set("key-a", &"secret data").await.unwrap();
let stored = backend.store.lock().await.get("key-a").cloned().unwrap();
backend
.store
.lock()
.await
.insert("key-b".to_owned(), stored);
let result: Result<Option<String>, _> = secure.get("key-b").await;
assert!(
result.is_err(),
"decryption with wrong cache key AAD must fail"
);
}
#[tokio::test]
async fn secure_different_tenants_cant_decrypt() {
let (shared_a, _backend) = MockBackend::new_with_handle();
let shared_b = shared_a.clone();
let client_a = CacheKit::builder()
.backend(shared_a)
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "tenant-a")
.unwrap()
.build()
.unwrap();
let client_b = CacheKit::builder()
.backend(shared_b)
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "tenant-b")
.unwrap()
.build()
.unwrap();
client_a
.secure_cache()
.unwrap()
.set("shared-key", &"tenant-a-secret")
.await
.unwrap();
let result: Result<Option<String>, _> =
client_b.secure_cache().unwrap().get("shared-key").await;
assert!(
result.is_err(),
"cross-tenant decryption must fail (different derived keys)"
);
}
#[tokio::test]
async fn secure_hex_builder() {
let client = CacheKit::builder()
.backend(MockBackend::shared())
.no_l1()
.encryption(&test_master_key_hex(), "hex-tenant")
.expect("hex encryption setup")
.build()
.unwrap();
let secure = client.secure_cache().unwrap();
secure.set("hex-test", &42u64).await.unwrap();
let val: u64 = secure.get("hex-test").await.unwrap().unwrap();
assert_eq!(val, 42);
}
#[test]
fn hex_builder_error_does_not_quote_the_key() {
let key = format!("{}Q9", "a1".repeat(31));
let err = CacheKit::builder()
.backend(MockBackend::shared())
.encryption(&key, "hex-tenant")
.err()
.expect("non-hex key must be rejected");
assert!(matches!(err, CachekitError::Config(_)), "got {err:?}");
assert!(
!err.to_string().contains('Q'),
"error quotes key material: {err}"
);
}
#[tokio::test]
async fn secure_with_l1_roundtrip() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared);
let secure = client.secure_cache().unwrap();
secure.set("l1-test", &"encrypted in L1").await.unwrap();
let val: String = secure.get("l1-test").await.unwrap().unwrap();
assert_eq!(val, "encrypted in L1");
backend.store.lock().await.remove("l1-test");
let val2: String = secure.get("l1-test").await.unwrap().unwrap();
assert_eq!(val2, "encrypted in L1");
}
#[tokio::test]
async fn secure_l1_stores_ciphertext_not_plaintext() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared);
let secure = client.secure_cache().unwrap();
secure.set("l1-cipher", &"PLAINTEXT_VALUE").await.unwrap();
let store = backend.store.lock().await;
let (_key, raw_bytes) = store.iter().next().expect("backend should have one entry");
let plaintext_msgpack = rmp_serde::to_vec_named(&"PLAINTEXT_VALUE").unwrap();
assert_ne!(
raw_bytes, &plaintext_msgpack,
"backend should store ciphertext, not plaintext msgpack"
);
assert!(
raw_bytes.len() > plaintext_msgpack.len(),
"ciphertext should be larger than plaintext due to AAD + GCM tag"
);
}
async fn plant_tampered(shared: SharedBackend, backend: &MockBackend, key: &str) -> Vec<u8> {
let writer = make_encrypted_client(shared);
writer
.secure_cache()
.unwrap()
.set(key, &"authentic")
.await
.unwrap();
let mut store = backend.store.lock().await;
let bytes = store.get_mut(key).expect("writer stored the entry");
*bytes.last_mut().unwrap() ^= 0x01;
bytes.clone()
}
#[tokio::test]
async fn secure_get_evicts_l1_after_decrypt_failure() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared.clone());
let secure = client.secure_cache().unwrap();
let planted = plant_tampered(shared, &backend, "poisoned").await;
let err = secure.get::<String>("poisoned").await.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
assert_eq!(backend.store.lock().await.get("poisoned"), Some(&planted));
backend.store.lock().await.remove("poisoned");
assert_eq!(secure.get::<String>("poisoned").await.unwrap(), None);
}
#[tokio::test]
async fn secure_interop_get_swr_evicts_l1_after_decrypt_failure() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared.clone());
let secure = client.secure_cache().unwrap();
let planted = plant_tampered(shared, &backend, "poisoned-swr").await;
let err = secure
.interop_get_swr::<String>("poisoned-swr")
.await
.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
assert_eq!(
backend.store.lock().await.get("poisoned-swr"),
Some(&planted)
);
backend.store.lock().await.remove("poisoned-swr");
assert!(matches!(
secure.interop_get_swr::<String>("poisoned-swr").await,
Ok(cachekit::SwrRead::Miss)
));
}
#[tokio::test]
async fn secure_with_namespace() {
let (shared, backend) = MockBackend::new_with_handle();
let client = CacheKit::builder()
.backend(shared)
.namespace("ns")
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.unwrap()
.build()
.unwrap();
let secure = client.secure_cache().unwrap();
secure.set("namespaced", &"value").await.unwrap();
let keys: Vec<String> = backend.store.lock().await.keys().cloned().collect();
assert!(
keys.contains(&"ns:namespaced".to_owned()),
"expected namespaced key, got: {keys:?}"
);
let val: String = secure.get("namespaced").await.unwrap().unwrap();
assert_eq!(val, "value");
}
#[tokio::test]
async fn secure_namespace_is_bound_in_aad() {
let (shared, backend) = MockBackend::new_with_handle();
let client_in = |ns: &str| {
CacheKit::builder()
.backend(shared.clone())
.namespace(ns)
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "one-tenant")
.unwrap()
.build()
.unwrap()
};
let (app1, app2) = (client_in("app1"), client_in("app2"));
app1.secure_cache()
.unwrap()
.set("k1", &"v-from-app1")
.await
.unwrap();
let stored = backend.store.lock().await["app1:k1"].clone();
let layer = cachekit::EncryptionLayer::new(TEST_MASTER_KEY, "one-tenant").unwrap();
assert!(layer.decrypt(&stored, "app1:k1").is_ok());
assert!(layer.decrypt(&stored, "k1").is_err());
backend
.store
.lock()
.await
.insert("app2:k1".to_owned(), stored);
let err = app2
.secure_cache()
.unwrap()
.get::<String>("k1")
.await
.expect_err("ciphertext moved across namespaces must fail authentication");
assert!(
matches!(err, CachekitError::Encryption(_)),
"expected Encryption, got: {err:?}"
);
}
#[tokio::test]
async fn secure_set_rejects_payload_whose_ciphertext_exceeds_limit() {
let client = CacheKit::builder()
.backend(MockBackend::shared())
.no_l1()
.max_payload_bytes(64)
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds");
let secure = client.secure_cache().expect("secure handle");
let value = "x".repeat(48); let err = secure
.set("boundary:key", &value)
.await
.expect_err("ciphertext over limit must fail at set time");
assert!(
matches!(err, CachekitError::PayloadTooLarge { .. }),
"expected PayloadTooLarge, got: {err:?}"
);
secure.set("small:key", &"ok").await.expect("small set");
let got: Option<String> = secure.get("small:key").await.expect("small get");
assert_eq!(got.as_deref(), Some("ok"));
}
#[tokio::test]
async fn rotation_round_trip_without_reencryption() {
const K1: &[u8] = &[0x11; 32];
const K2: &[u8] = &[0x22; 32];
let (backend, store) = common::MockBackend::new_with_handle();
let writer = CacheKit::builder()
.backend(backend.clone())
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes(K1, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds");
let secret = Secret {
api_key: "sk-live-rotate-me".to_owned(), user_id: 7,
};
writer
.secure_cache()
.expect("secure_cache()")
.set("secret:7", &secret)
.await
.expect("secure set under k1");
let ciphertext_before = store.store.lock().await.get("secret:7").cloned().unwrap();
let rotated = CacheKit::builder()
.backend(backend.clone())
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes_with_previous(K2, &[K1], "test-tenant")
.expect("keyring setup")
.build()
.expect("client builds");
let read_back: Secret = rotated
.secure_cache()
.expect("secure_cache()")
.get("secret:7")
.await
.expect("secure get after rotation")
.expect("value should exist");
assert_eq!(read_back, secret);
let ciphertext_after = store.store.lock().await.get("secret:7").cloned().unwrap();
assert_eq!(
ciphertext_before, ciphertext_after,
"read-through rotation must not rewrite the entry"
);
let cut_over = CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes_with_previous(K2, &[], "test-tenant")
.expect("keyring setup")
.build()
.expect("client builds");
let result: Result<Option<Secret>, _> = cut_over
.secure_cache()
.expect("secure_cache()")
.get("secret:7")
.await;
assert!(
matches!(result, Err(CachekitError::Encryption(_))),
"dropped-key read must surface as an encryption error, got {result:?}"
);
}
#[tokio::test]
async fn rotation_drain_signal_is_visible_on_secure_cache() {
const K1: &[u8] = &[0x11; 32];
const K2: &[u8] = &[0x22; 32];
let backend = common::MockBackend::shared();
let writer = CacheKit::builder()
.backend(backend.clone())
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes(K1, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds");
writer
.secure_cache()
.expect("secure_cache()")
.set("drain:old", &"written under k1")
.await
.expect("secure set under k1");
let rotated = CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes_with_previous(K2, &[K1], "test-tenant")
.expect("keyring setup")
.build()
.expect("client builds");
let secure = rotated.secure_cache().expect("secure_cache()");
assert_eq!(secure.previous_key_hits(), vec![0], "nothing read yet");
let _: Option<String> = secure.get("drain:old").await.expect("secure get");
assert_eq!(
secure.previous_key_hits(),
vec![1],
"previous-key hit is counted"
);
}
#[tokio::test]
async fn hex_rotation_reads_a_48_byte_key_entry_for_a_custom_tenant() {
const TENANT: &str = "tenant-a";
let old_hex = "11".repeat(48);
let new_hex = "22".repeat(32);
let backend = MockBackend::shared();
let client = |configure: &dyn Fn(
cachekit::CacheKitBuilder,
) -> Result<cachekit::CacheKitBuilder, CachekitError>| {
let builder = CacheKit::builder()
.backend(backend.clone())
.default_ttl(Duration::from_secs(60))
.no_l1();
configure(builder)
.expect("encryption setup")
.build()
.expect("client builds")
};
let secret = plain_secret();
let writer = client(&|b| b.encryption(&old_hex, TENANT));
writer.set("rotate:old", &secret).await.unwrap();
let rotated = client(&|b| b.encryption_with_previous(&new_hex, &[&old_hex], TENANT));
assert_eq!(
rotated.get::<Secret>("rotate:old").await.unwrap(),
Some(secret.clone()),
"the 48-byte-key entry must stay readable after rotation"
);
assert_eq!(
rotated.secure_cache().unwrap().previous_key_hits(),
vec![1],
"the read was served by the previous key"
);
rotated.set("rotate:new", &secret).await.unwrap();
let new_only = client(&|b| b.encryption(&new_hex, TENANT));
assert_eq!(
new_only.get::<Secret>("rotate:new").await.unwrap(),
Some(secret.clone()),
"a write after rotation must decrypt under the new key alone"
);
let old_only = client(&|b| b.encryption(&old_hex, TENANT));
let result = old_only.get::<Secret>("rotate:new").await;
assert!(
matches!(result, Err(CachekitError::Encryption(_))),
"a write after rotation must not be under the old key, got {result:?}"
);
}
#[track_caller]
fn assert_builder_config_err_naming(
result: Result<cachekit::CacheKitBuilder, CachekitError>,
names: &str,
what: &str,
) {
match result {
Err(CachekitError::Config(msg)) => {
assert!(
msg.contains(names),
"{what}: Config error must name {names:?}, got: {msg}"
)
}
Err(e) => panic!("{what}: expected Config error, got {e:?}"),
Ok(_) => panic!("{what}: expected Config error, got Ok"),
}
}
#[test]
fn encryption_with_previous_enforces_the_keyring_rules() {
let current = "22".repeat(32);
let (k1, k2, k3, k4) = (
"11".repeat(32),
"33".repeat(32),
"44".repeat(48),
"55".repeat(32),
);
let ok = |prev: &[&str], tenant: &str| {
CacheKit::builder().encryption_with_previous(¤t, prev, tenant)
};
assert!(
ok(&[&k1, &k2, &k3], "t").is_ok(),
"3 previous keys are allowed"
);
assert!(ok(&[], "t").is_ok(), "no previous keys is allowed");
assert!(
ok(&[&k1], &"t".repeat(255)).is_ok(),
"a 255-byte tenant is allowed"
);
assert_builder_config_err_naming(ok(&[&k1, &k2, &k3, &k4], "t"), "keyring", "4 previous keys");
assert_builder_config_err_naming(
ok(&[&k1, ¤t], "t"),
"keyring",
"current key among previous",
);
assert_builder_config_err_naming(ok(&[&k1], ""), "tenant_id", "empty tenant");
assert_builder_config_err_naming(ok(&[&k1], &"t".repeat(256)), "tenant_id", "256-byte tenant");
for (bad, why) in [
("11".repeat(31), "31-byte previous key"),
("zz".repeat(32), "non-hex previous key"),
("1".repeat(65), "odd-length previous key"),
(String::new(), "empty previous key"),
] {
assert_builder_config_err_naming(ok(&[&k1, &bad], "t"), "previous master key 1", why);
}
for (bad, why) in [
("11".repeat(31), "31-byte current key"),
("zz".repeat(32), "non-hex current key"),
] {
assert_builder_config_err_naming(
CacheKit::builder().encryption_with_previous(&bad, &[&k1], "t"),
"master key",
why,
);
}
}
type Configure = fn(cachekit::CacheKitBuilder) -> Result<cachekit::CacheKitBuilder, CachekitError>;
const SPELLINGS: [(&str, Configure); 4] = [
("encryption", |b| {
b.encryption(&test_master_key_hex(), "test-tenant")
}),
("encryption_from_bytes", |b| {
b.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
}),
("encryption_from_bytes_with_previous", |b| {
b.encryption_from_bytes_with_previous(TEST_MASTER_KEY, &[&[0x11; 32]], "test-tenant")
}),
("encryption_with_previous", |b| {
b.encryption_with_previous(&test_master_key_hex(), &[&"11".repeat(32)], "test-tenant")
}),
];
fn plain_client(configure: Configure, backend: SharedBackend, l1: bool) -> CacheKit {
let builder = CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60));
let builder = if l1 {
builder.l1_capacity(100)
} else {
builder.no_l1()
};
configure(builder)
.expect("encryption setup")
.build()
.expect("client builds")
}
fn plain_secret() -> Secret {
Secret {
api_key: "sk-live-PLAINPATH".to_owned(), user_id: 7,
}
}
async fn assert_backend_holds_ciphertext_of<T: Serialize>(
backend: &MockBackend,
key: &str,
value: &T,
how: &str,
) {
let stored = backend
.store
.lock()
.await
.get(key)
.cloned()
.unwrap_or_else(|| panic!("{how}: {key} never reached the backend"));
let plaintext = cachekit::serializer::serialize(value).unwrap();
assert_ne!(stored, plaintext, "{how}: {key} stored as plaintext");
let decrypted = cachekit::EncryptionLayer::new(TEST_MASTER_KEY, "test-tenant")
.unwrap()
.decrypt(&stored, key)
.unwrap_or_else(|e| panic!("{how}: {key} is not ciphertext under the key: {e}"));
assert_eq!(decrypted, plaintext, "{how}: {key}");
}
#[tokio::test]
async fn plain_set_and_set_with_ttl_store_ciphertext() {
let secret = plain_secret();
for (how, configure) in SPELLINGS {
let (shared, backend) = MockBackend::new_with_handle();
let client = plain_client(configure, shared, false);
client.set("plain:set", &secret).await.unwrap();
client
.set_with_ttl("plain:ttl", &secret, Duration::from_secs(30))
.await
.unwrap();
assert_backend_holds_ciphertext_of(&backend, "plain:set", &secret, how).await;
assert_backend_holds_ciphertext_of(&backend, "plain:ttl", &secret, how).await;
}
}
#[tokio::test]
async fn plain_reads_decrypt_what_plain_writes_store() {
let secret = plain_secret();
for (how, configure) in SPELLINGS {
let client = plain_client(configure, MockBackend::shared(), false);
client.set("plain:rt", &secret).await.unwrap();
assert_eq!(
client.get::<Secret>("plain:rt").await.unwrap(),
Some(secret.clone()),
"{how}: get"
);
assert_eq!(
client.interop_get::<Secret>("plain:rt").await.unwrap(),
Some(secret.clone()),
"{how}: interop_get"
);
assert_eq!(
client.interop_get_swr::<Secret>("plain:rt").await.unwrap(),
cachekit::SwrRead::Fresh(secret.clone()),
"{how}: interop_get_swr"
);
let secure = client.secure_cache().unwrap();
assert_eq!(
secure.get::<Secret>("plain:rt").await.unwrap(),
Some(secret.clone()),
"{how}: handle reads a plain write"
);
secure.set("secure:rt", &secret).await.unwrap();
assert_eq!(
client.get::<Secret>("secure:rt").await.unwrap(),
Some(secret.clone()),
"{how}: plain get reads a handle write"
);
}
}
#[tokio::test]
async fn plain_writes_keep_ciphertext_in_l1() {
let secret = plain_secret();
for (how, configure) in SPELLINGS {
let (shared, backend) = MockBackend::new_with_handle();
let client = plain_client(configure, shared, true);
client.set("plain:l1", &secret).await.unwrap();
backend.store.lock().await.clear();
let secure = client.secure_cache().unwrap();
assert_eq!(
secure.get::<Secret>("plain:l1").await.unwrap(),
Some(secret.clone()),
"{how}"
);
assert_eq!(client.stats().l1_hits, 1, "{how}: served from L1");
}
}
#[tokio::test]
async fn plain_reads_reject_a_plaintext_entry() {
let (shared, _backend) = MockBackend::new_with_handle();
CacheKit::builder()
.backend(shared.clone())
.no_l1()
.build()
.unwrap()
.set("legacy", &"in the clear")
.await
.unwrap();
let client = make_encrypted_client(shared);
let err = client.get::<String>("legacy").await.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
let err = client.interop_get::<String>("legacy").await.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
let err = client
.interop_get_swr::<String>("legacy")
.await
.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
client.set("legacy", &"sealed").await.unwrap();
assert_eq!(
client.get::<String>("legacy").await.unwrap().as_deref(),
Some("sealed")
);
}
#[tokio::test]
async fn decode_error_after_decrypt_does_not_quote_the_value() {
let client = make_encrypted_client(MockBackend::shared());
client.set("typed", &"sk-live-REDACTME").await.unwrap(); let secure = client.secure_cache().unwrap();
for (how, result) in [
("get", client.get::<Secret>("typed").await.map(drop)),
(
"interop_get",
client.interop_get::<Secret>("typed").await.map(drop),
),
(
"interop_get_swr",
client.interop_get_swr::<Secret>("typed").await.map(drop),
),
("handle get", secure.get::<Secret>("typed").await.map(drop)),
] {
match result {
Err(CachekitError::Serialization(msg)) => {
assert!(!msg.contains("REDACTME"), "{how} quotes the value: {msg}");
assert!(
msg.contains("Secret"),
"{how} must name the target type: {msg}"
);
}
other => panic!("{how}: expected a Serialization error, got {other:?}"),
}
}
}
#[tokio::test]
async fn secure_get_rejects_a_decrypted_plaintext_with_trailing_bytes() {
let (backend, handle) = MockBackend::new_with_handle();
let client = make_encrypted_client(backend);
let mut plaintext = cachekit::serializer::serialize(&7u8).unwrap();
plaintext.push(0x00);
let sealed = cachekit::EncryptionLayer::new(TEST_MASTER_KEY, "test-tenant")
.unwrap()
.encrypt(&plaintext, "trailing")
.unwrap();
handle
.store
.lock()
.await
.insert("trailing".to_owned(), sealed);
let secure = client.secure_cache().unwrap();
let err = secure.get::<u8>("trailing").await.unwrap_err();
assert!(
matches!(err, CachekitError::Serialization(_)),
"got: {err:?}"
);
}
#[track_caller]
fn assert_builder_config_err(result: Result<cachekit::CacheKitBuilder, CachekitError>, what: &str) {
assert_builder_config_err_naming(result, "", what);
}
#[test]
fn encryption_from_bytes_requires_exactly_32_bytes() {
for len in [31, 33] {
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes(&vec![7u8; len], "t"),
&format!("{len}-byte key"),
);
}
assert!(CacheKit::builder()
.encryption_from_bytes(&[7u8; 32], "t")
.is_ok());
}
#[test]
fn encryption_from_bytes_rejects_ascii_hex() {
let hex = "ab".repeat(32);
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes(hex.as_bytes(), "t"),
"ASCII hex as raw bytes",
);
}
#[test]
fn encryption_from_bytes_with_previous_requires_exactly_32_bytes() {
const K1: &[u8] = &[0x11; 32];
const K2: &[u8] = &[0x22; 32];
for len in [31, 33] {
let bad = vec![7u8; len];
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes_with_previous(&bad, &[K1], "t"),
&format!("{len}-byte current key"),
);
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes_with_previous(K2, &[&bad], "t"),
&format!("{len}-byte previous key"),
);
}
assert!(CacheKit::builder()
.encryption_from_bytes_with_previous(K2, &[K1], "t")
.is_ok());
}
#[test]
fn encryption_hex_accepts_32_and_48_byte_keys() {
for bytes in [32, 48] {
assert!(
CacheKit::builder()
.encryption(&"ab".repeat(bytes), "t")
.is_ok(),
"{bytes}-byte hex key must be accepted"
);
}
}
#[cfg(feature = "cachekitio")]
fn key_env(master: Option<&str>, previous: Option<&str>) -> common::EnvGuard {
common::EnvGuard::set(&[
("CACHEKIT_API_KEY", Some("test-key")),
("CACHEKIT_API_URL", None),
("CACHEKIT_MASTER_KEY", master),
("CACHEKIT_PREVIOUS_MASTER_KEYS", previous),
])
}
#[cfg(feature = "cachekitio")]
#[test]
#[serial_test::serial]
fn encryption_from_env_accepts_32_and_48_byte_hex_keys() {
for bytes in [32, 48] {
let (current, previous) = ("22".repeat(bytes), "11".repeat(bytes));
let _env = key_env(Some(¤t), Some(&previous));
let builder = CacheKit::from_env()
.and_then(|b| b.encryption_from_env())
.unwrap_or_else(|e| panic!("{bytes}-byte hex keys must be accepted: {e}"));
assert!(builder.build().unwrap().secure_cache().is_ok());
}
}
#[cfg(feature = "cachekitio")]
fn stored_bytes(builder: cachekit::CacheKitBuilder) -> Option<Vec<u8>> {
let (backend, handle) = MockBackend::new_with_handle();
let cache = builder
.backend(backend)
.no_l1()
.build()
.expect("client builds");
assert!(
cache.secure_cache().is_err(),
"no encryption layer expected"
);
common::runtime()
.block_on(cache.set("k", &"plain value"))
.expect("set");
let stored = handle.store.try_lock().unwrap().get("k").cloned();
stored
}
#[cfg(feature = "cachekitio")]
#[test]
#[serial_test::serial]
fn from_env_with_a_master_key_and_no_encryption_stores_plaintext() {
let (current, previous) = ("22".repeat(32), "11".repeat(32));
let _env = key_env(Some(¤t), Some(&previous));
let builder = CacheKit::from_env().expect("a valid key must not stop from_env");
assert_eq!(
stored_bytes(builder.no_encryption()),
Some(cachekit::serializer::serialize(&"plain value").unwrap()),
"from_env() must not encrypt because CACHEKIT_MASTER_KEY is set"
);
}
#[cfg(feature = "cachekitio")]
#[test]
#[serial_test::serial]
fn from_env_with_a_master_key_and_no_stated_intent_does_not_build() {
let current = "22".repeat(32);
let _env = key_env(Some(¤t), None);
match CacheKit::from_env().and_then(|b| b.build()) {
Err(CachekitError::Config(msg)) => assert!(
msg.contains(".encryption_from_env()") && msg.contains(".no_encryption()"),
"the error must name both spellings: {msg}"
),
Err(e) => panic!("expected a Config error, got {e:?}"),
Ok(_) => panic!("from_env() with a key built without a stated intent"),
}
let _no_key = key_env(None, None);
assert_eq!(
stored_bytes(CacheKit::from_env().expect("from_env")),
Some(cachekit::serializer::serialize(&"plain value").unwrap()),
);
}
#[test]
fn no_encryption_with_an_encryption_call_is_a_config_error() {
let key = test_master_key_hex();
for builder in [
CacheKit::builder()
.encryption(&key, "t")
.unwrap()
.no_encryption(),
CacheKit::builder()
.no_encryption()
.encryption(&key, "t")
.unwrap(),
] {
let result = builder.backend(MockBackend::shared()).build();
assert!(
matches!(&result, Err(CachekitError::Config(msg)) if msg.contains(".no_encryption()")),
"expected a Config error naming .no_encryption(), got {:?}",
result.err()
);
}
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn encryption_from_env_rejects_a_non_utf8_key_naming_it() {
use std::os::unix::ffi::OsStrExt;
let _env = common::EnvGuard::set(&[
("CACHEKIT_MASTER_KEY", None),
("CACHEKIT_PREVIOUS_MASTER_KEYS", None),
]);
std::env::set_var(
"CACHEKIT_MASTER_KEY",
std::ffi::OsStr::from_bytes(&[0xff; 32]),
);
match CacheKit::builder().encryption_from_env() {
Err(CachekitError::Config(msg)) => assert!(msg.contains("not valid UTF-8"), "{msg}"),
Err(e) => panic!("expected a Config error, got {e:?}"),
Ok(_) => panic!("a non-UTF-8 key was accepted"),
}
}
#[cfg(feature = "cachekitio")]
#[test]
#[serial_test::serial]
fn encryption_from_env_uses_the_env_keys() {
let (current, previous) = ("22".repeat(32), "11".repeat(32));
let _env = key_env(Some(¤t), Some(&previous));
let (backend, handle) = MockBackend::new_with_handle();
let explicit = |hex: &str| {
CacheKit::builder()
.backend(backend.clone())
.no_l1()
.encryption(hex, "default")
.and_then(|b| b.build())
.expect("explicit client builds")
};
let cache = CacheKit::from_env()
.and_then(|b| b.encryption_from_env())
.expect("env keys are valid")
.backend(backend.clone())
.no_l1()
.build()
.expect("client builds");
let rt = common::runtime();
rt.block_on(cache.set("k", &"secret")).expect("set");
let stored = handle.store.try_lock().unwrap().get("k").cloned().unwrap();
assert_ne!(stored, cachekit::serializer::serialize(&"secret").unwrap());
let read: Option<String> = rt.block_on(explicit(¤t).get("k")).expect("get");
assert_eq!(
read.as_deref(),
Some("secret"),
"written under the current key"
);
rt.block_on(explicit(&previous).set("old", &"rotated"))
.expect("set");
let read: Option<String> = rt.block_on(cache.get("old")).expect("get");
assert_eq!(read.as_deref(), Some("rotated"), "previous key decrypts");
}
#[cfg(feature = "cachekitio")]
#[test]
#[serial_test::serial]
fn encryption_from_env_without_a_key_is_a_config_error() {
for (master, says) in [(None, "unset"), (Some(""), "empty")] {
let _env = key_env(master, None);
for (entry, result) in [
("builder", CacheKit::builder().encryption_from_env()),
(
"from_env",
CacheKit::from_env().and_then(|b| b.encryption_from_env()),
),
] {
match result {
Err(CachekitError::Config(msg)) => assert!(
msg.contains("CACHEKIT_MASTER_KEY") && msg.contains(says),
"{entry} {master:?}: {msg}"
),
Err(e) => panic!("{entry} {master:?}: expected a Config error, got {e:?}"),
Ok(_) => panic!("{entry} {master:?}: encryption accepted with no key"),
}
}
}
}