diff --git a/README.md b/README.md index 69e8659..a7e8594 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,7 @@ let user: Option = cache.interop_get(&key).await?; // strict read: exactly `ns` and `nsapi` are reserved as namespaces — the CacheKit Cloud server parses a key starting `ns:` or `nsapi:` as namespace-prefixed — so `interop_key` rejects them with `InvalidKey` and `#[cachekit(namespace = ...)]` with a compile error; operations are unaffected. Neither segment may contain `..` (`a..b` is rejected the same two ways; a lone `.`, as in `app.v1`, is fine), because the server rejects `..` anywhere in a key. On a `#[cachekit]` function, a parameter whose name starts with `__ck` or `__CK` is a compile error too: that prefix is reserved for the macro's generated locals, which would otherwise shadow the argument. -Argument hashing is byte-identical across SDKs (canonical MessagePack + Blake2b-256), verified against the shared [protocol](https://github.com/cachekit-io/protocol) test vectors ([`interop-mode.json`](crates/cachekit/tests/vectors/interop-mode.json), vendored) in this repo's test suite. `get` and `interop_get` (also on `SecureCache`) both read exactly one MessagePack document and reject trailing bytes, so neither silently misreads a Python-internal CK frame as the integer 67; `interop_get` names the CK frame in its error. On a client without encryption, `get`, `interop_get` and `interop_get_swr` refuse every CK frame in the protocol's shared [`python-frame.json`](crates/cachekit/tests/vectors/python-frame.json) vectors (vendored); an encrypted client refuses them at decrypt. A bare ByteStorage envelope (another SDK's auto-mode container) is one well-formed MessagePack document, so no structural check can refuse it: on a client without encryption, a read of one fails only when its shape does not fit the requested type, and rmp-serde decodes by position, so a type whose four elements line up with the envelope's (bytes, 8 integers, an integer, a string) does read one. An encrypting client fails at decrypt, whatever the type. Every decode of backend-supplied bytes (`get` and `interop_get` alike) first passes a header-only structural walk that rejects, before anything is decoded, a document nested deeper than `serializer::MAX_DECODE_DEPTH` (100 levels, matching the TypeScript SDK; every collection header counts, empty ones included) or declaring more elements or bytes than the input can back, verified against the protocol's shared [`decode-bounds.json`](crates/cachekit/tests/vectors/decode-bounds.json) vectors (vendored) — a forged nested-header entry is a bounded `Serialization` error, not a memory blow-up or a stack overflow. A client without encryption walks the bytes when a backend read or its own write admits them, and a later L1 hit on bytes that passed decodes without walking again. Encryption works unchanged — interop keys are identical across SDKs, so the AAD verifies cross-SDK. On a client with encryption configured, `set_with_ttl` stores the AES-GCM ciphertext of that MessagePack and `interop_get` decrypts it. +Argument hashing is byte-identical across SDKs (canonical MessagePack + Blake2b-256), verified against the shared [protocol](https://github.com/cachekit-io/protocol) test vectors ([`interop-mode.json`](crates/cachekit/tests/vectors/interop-mode.json), vendored) in this repo's test suite. `get` and `interop_get` (also on `SecureCache`) both read exactly one MessagePack document and reject trailing bytes, so neither silently misreads a Python-internal CK frame as the integer 67; `interop_get` names the CK frame in its error. On a client without encryption, `get`, `interop_get` and `interop_get_swr` refuse every CK frame in the protocol's shared [`python-frame.json`](crates/cachekit/tests/vectors/python-frame.json) vectors (vendored); an encrypted client refuses them at decrypt. A bare ByteStorage envelope (another SDK's auto-mode container) is one well-formed MessagePack document, so no structural check can refuse it: on a client without encryption, a read of one fails only when its shape does not fit the requested type, and rmp-serde decodes by position, so a type whose four elements line up with the envelope's (bytes, 8 integers, an integer, a string) does read one. An encrypting client fails at decrypt, whatever the type. Every decode of backend-supplied bytes (`get` and `interop_get` alike) first passes a header-only structural walk that rejects, before anything is decoded, a document nested deeper than `serializer::MAX_DECODE_DEPTH` (100 levels, matching the TypeScript SDK; every collection header counts, empty ones included) or declaring more elements or bytes than the input can back, verified against the protocol's shared [`decode-bounds.json`](crates/cachekit/tests/vectors/decode-bounds.json) vectors (vendored) — a forged nested-header entry is a bounded `Serialization` error, not a memory blow-up or a stack overflow. A client without encryption walks the bytes when a backend read or its own write admits them, and a later L1 hit on bytes that passed decodes without walking again. An encrypting client's L1 holds ciphertext: every read decrypts it, and the first L1 hit whose decrypted plaintext passes the walk marks the entry, so later hits on that ciphertext decode without walking again. A rewrite, delete or eviction drops the mark with the entry, and a hostile plaintext under a valid tag is never marked. Encryption works unchanged — interop keys are identical across SDKs, so the AAD verifies cross-SDK. On a client with encryption configured, `set_with_ttl` stores the AES-GCM ciphertext of that MessagePack and `interop_get` decrypts it. > [!WARNING] > Every service that binds one `(namespace, operation)` must agree on encryption: on in all of them or off in all of them, with the same master key and `tenant_id` (decrypt-only previous master keys may differ). If an unencrypted service shares the operation with an encrypted one, values end up **stored unencrypted** at the shared key: the unencrypted service writes in the clear whenever it fills the entry, and a `#[cachekit]` function on a client without encryption also replaces ciphertext it cannot decode. See [Shared entries are a contract](https://docs.cachekit.io/concepts/using-interop-mode/#shared-entries-are-a-contract). @@ -928,8 +928,8 @@ targets turn on. `l1_hit_row` reads an L1 hit whose value is a 20-field struct, stored as a MessagePack map of 20 entries, so it prices decoding a many-field value: a bounds walk, where one runs, covers 41 headers where a `String` value has one. -It runs `plain` only, since it prices a plaintext decode: the encrypted modes -decode decrypted plaintext, which is walked on every read. +It runs `plain` and `enc`, where the value decoded is the decrypted +plaintext. `ns` and `nsenc` run `l1_hit`, `l2_hit` and `set` as `plain` and `enc` on a client with a `.namespace()`, which builds `{namespace}:{key}` on every op. `delete/parked256` and `delete/parked1024` run the plain 64 B `delete` while diff --git a/crates/cachekit/examples/bench_hot_path.rs b/crates/cachekit/examples/bench_hot_path.rs index c9e6dbb..71c470c 100644 --- a/crates/cachekit/examples/bench_hot_path.rs +++ b/crates/cachekit/examples/bench_hot_path.rs @@ -333,9 +333,8 @@ fn is_case(op: &str, mode: &str, size: usize) -> bool { "oneshot" => matches!(mode, "enc" | "prev"), "macro_hit" => cfg!(feature = "macros") && !matches!(mode, "prev" | "ns" | "nsenc"), "l1_hit" | "l2_hit" => true, - // A plaintext decode: the encrypted modes decode decrypted plaintext, - // which is walked on every read. - "l1_hit_row" => mode == "plain", + // A many-field decode, of the stored bytes or of decrypted plaintext. + "l1_hit_row" => matches!(mode, "plain" | "enc"), "set" => mode != "prev", _ => !matches!(mode, "prev" | "ns" | "nsenc"), } diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index dedc847..75053a2 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -440,7 +440,8 @@ impl CacheKit { /// read that is before the L1 backfill, which runs before any decode, so /// L1 only holds a mark the bytes earned, and the read's own decode does /// not walk again. An encrypting client's bytes are ciphertext: they are - /// not MessagePack, and the decrypted plaintext is walked on every read. + /// not MessagePack, so they are admitted unwalked, and the first L1 hit + /// whose decrypted plaintext passes the walk marks them (`SecureCache::open`). #[cfg_attr(not(feature = "encryption"), allow(clippy::unused_self))] fn admit(&self, bytes: Bytes) -> Payload { #[cfg(feature = "encryption")] @@ -527,6 +528,14 @@ impl CacheKit { } } + /// See [`crate::l1::L1Cache::adopt`]. + #[cfg(all(feature = "l1", feature = "encryption"))] + fn l1_adopt(&self, full_key: &str, marked: Payload) { + if let Some(ref l1) = self.l1 { + l1.adopt(full_key, marked); + } + } + #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))] async fn lock_l1_mutation(&self, full_key: &str) -> Option { if self.l1.is_some() { @@ -1290,13 +1299,18 @@ impl CacheKit { key: &str, lead: LeadSlot<'_>, ) -> Result, CachekitError> { + self.reject_namespaced_interop()?; + let full_key = self.resolve_key(key)?; + let read = self.read_bytes_swr(&full_key, lead).await?; + // Plain and encrypting clients read the same bytes and differ only in + // the decode, so branch after the await, synchronously: an awaited + // secure read's future would sit in this future's state, which every + // plain read shares. #[cfg(feature = "encryption")] if let Some(secure) = self.encrypting() { - return secure.__interop_read_swr(key, lead).await; + return secure.open_swr(read, &full_key); } - self.reject_namespaced_interop()?; - let full_key = self.resolve_key(key)?; - Ok(match self.read_bytes_swr(&full_key, lead).await? { + Ok(match read { SwrRead::Fresh((payload, by)) => { SwrRead::Fresh((crate::interop::deserialize_payload(&payload)?, by)) } @@ -1532,8 +1546,12 @@ impl SecureCache<'_> { let Some((ciphertext, by)) = read_l1_first!(self.client, &full_key) else { return Ok(None); }; - let plaintext = self.decrypt_or_evict(ciphertext.bytes(), &full_key)?; - let value = redact_decode(serializer::deserialize(&plaintext))?; + let value = self.open( + &ciphertext, + by, + &full_key, + serializer::deserialize_decrypted, + )?; self.client.l1_stop(sample, by); Ok(Some(value)) } @@ -1563,8 +1581,12 @@ impl SecureCache<'_> { let Some((ciphertext, by)) = read_l1_first!(self.client, &full_key) else { return Ok(None); }; - let plaintext = self.decrypt_or_evict(ciphertext.bytes(), &full_key)?; - let value = redact_decode(crate::interop::deserialize(&plaintext))?; + let value = self.open( + &ciphertext, + by, + &full_key, + crate::interop::deserialize_decrypted, + )?; self.client.l1_stop(sample, by); Ok(Some(value)) } @@ -1586,14 +1608,11 @@ impl SecureCache<'_> { let sample = self.client.l1_sample(); self.client.reject_namespaced_interop()?; let full_key = self.client.resolve_key(key)?; - let decode = |ct: &[u8]| -> Result { - redact_decode(crate::interop::deserialize( - &self.decrypt_or_evict(ct, &full_key)?, - )) - }; + let decode = + |ct: &Payload, by| self.open(ct, by, &full_key, crate::interop::deserialize_decrypted); let (read, by) = match self.client.get_bytes_swr(&full_key).await? { - SwrRead::Fresh((ct, by)) => (SwrRead::Fresh(decode(ct.bytes())?), by), - SwrRead::Stale((ct, by), token) => (SwrRead::Stale(decode(ct.bytes())?, token), by), + SwrRead::Fresh((ct, by)) => (SwrRead::Fresh(decode(&ct, by)?), by), + SwrRead::Stale((ct, by), token) => (SwrRead::Stale(decode(&ct, by)?, token), by), SwrRead::Miss => return Ok(SwrRead::Miss), }; self.client.l1_stop(sample, by); @@ -1610,37 +1629,62 @@ impl SecureCache<'_> { let full_key = self.client.resolve_key(key)?; match self.client.read_bytes(&full_key, None).await? { Some((ct, by)) => Ok(Some(( - redact_decode(crate::interop::deserialize( - &self.decrypt_or_evict(ct.bytes(), &full_key)?, - ))?, + self.open(&ct, by, &full_key, crate::interop::deserialize_decrypted)?, by, ))), None => Ok(None), } } - /// The secure twin of [`CacheKit::__interop_read_swr`] and - /// [`CacheKit::__interop_read_swr_leading`], which hand an encrypting - /// client's reads here. - async fn __interop_read_swr( + /// [`Self::open`] over `read`, what an encrypting client's + /// [`CacheKit::__interop_read_swr`] or + /// [`CacheKit::__interop_read_swr_leading`] read for `full_key`. + fn open_swr( &self, - key: &str, - lead: LeadSlot<'_>, + read: SwrRead<(Payload, ReadOutcome)>, + full_key: &str, ) -> Result, CachekitError> { - self.client.reject_namespaced_interop()?; - let full_key = self.client.resolve_key(key)?; - let decode = |ct: &[u8]| -> Result { - redact_decode(crate::interop::deserialize( - &self.decrypt_or_evict(ct, &full_key)?, - )) - }; - Ok(match self.client.read_bytes_swr(&full_key, lead).await? { - SwrRead::Fresh((ct, by)) => SwrRead::Fresh((decode(ct.bytes())?, by)), - SwrRead::Stale((ct, by), token) => SwrRead::Stale((decode(ct.bytes())?, by), token), + let decode = + |ct: &Payload, by| self.open(ct, by, full_key, crate::interop::deserialize_decrypted); + Ok(match read { + SwrRead::Fresh((ct, by)) => SwrRead::Fresh((decode(&ct, by)?, by)), + SwrRead::Stale((ct, by), token) => SwrRead::Stale((decode(&ct, by)?, by), token), SwrRead::Miss => SwrRead::Miss, }) } + /// Decrypt `ciphertext`, read for `full_key` from the layer `by`, and + /// decode its plaintext with `decode`, which walks the plaintext unless + /// the ciphertext's mark says it already passed. An L1 hit whose + /// plaintext passes the walk here marks its L1 entry, if L1 still holds + /// that ciphertext ([`crate::l1::L1Cache::adopt`]), so later hits on it + /// decode without the walk. An L2 hit marks nothing: L1 then pays one + /// write per key read twice, not one per key read. + /// + /// `decode` is a type parameter, not a function pointer, so each read + /// inlines its decoder as it did before the mark. + fn open( + &self, + ciphertext: &Payload, + by: ReadOutcome, + full_key: &str, + decode: impl FnOnce(&serializer::Decrypted<'_>) -> Result, + ) -> Result { + let decrypted = ciphertext.decrypt(|ct| self.decrypt_or_evict(ct, full_key))?; + // Marked before the result is checked: a plaintext that passed the + // walk and does not decode as `T` still passed the walk. + let decoded = decode(&decrypted); + #[cfg(feature = "l1")] + if matches!(by, ReadOutcome::L1Hit | ReadOutcome::L1Stale) { + if let Some(marked) = decrypted.marked() { + self.client.l1_adopt(full_key, marked); + } + } + #[cfg(not(feature = "l1"))] + let _ = by; + decoded.map_err(redact::) + } + /// Decrypt ciphertext read for `full_key`, the key this client passed to /// its backend (`.namespace()` prefix included, from /// [`CacheKit::resolve_key`]), which the AAD binds; on failure, drop the @@ -1680,14 +1724,14 @@ impl SecureCache<'_> { /// …`), and that value is the plaintext encryption exists to protect /// (CWE-532). #[cfg(feature = "encryption")] -fn redact_decode(decoded: Result) -> Result { - decoded.map_err(|e| match e { +fn redact(e: CachekitError) -> CachekitError { + match e { CachekitError::Serialization(_) => CachekitError::Serialization(format!( "decrypted entry does not decode as {}", std::any::type_name::() )), other => other, - }) + } } // ── CacheKitBuilder ─────────────────────────────────────────────────────────── @@ -2607,3 +2651,119 @@ mod walked_mark_tests { assert!(walked(&client, "good")); } } + +/// An encrypting client's L1 entry is marked once an L1 hit's decrypted +/// plaintext passes the walk, and only while L1 still holds the ciphertext +/// that hit read. That hostile plaintext stays unmarked is pinned on every +/// read path by `tests/decode_bounds_tests.rs`. +#[cfg(all(test, feature = "l1", feature = "encryption", not(feature = "unsync")))] +#[allow(clippy::unwrap_used, clippy::expect_used)] // test-only: a failed setup step should panic loudly +mod plaintext_mark_tests { + use super::{CacheKit, SharedBackend}; + use crate::backend::MemoryBackend; + + fn secure_client(backend: &MemoryBackend) -> CacheKit { + let shared: SharedBackend = std::sync::Arc::new(backend.clone()); + CacheKit::builder() + .backend(shared) + .l1_capacity(16) + .encryption_from_bytes(&[0x42; 32], "marks") + .unwrap() + .build() + .unwrap() + } + + #[test] + fn the_public_l1_set_stays_unwalked() { + let l1 = crate::l1::L1Cache::new(16); + l1.set("k", &[0x01], std::time::Duration::from_secs(60)); + let entry = l1.get_shared("k").unwrap(); + assert!(!entry.is_walked() && !entry.is_plaintext_walked()); + } + + fn is_marked(client: &CacheKit, key: &str) -> bool { + let entry = client.l1_get(key).expect("an L1 entry"); + assert!( + !entry.is_walked(), + "ciphertext never carries the plain mark" + ); + entry.is_plaintext_walked() + } + + #[tokio::test] + async fn an_own_write_is_marked_by_its_first_l1_hit() { + let client = secure_client(&MemoryBackend::default()); + client.set("k", &vec![1u32, 2, 3]).await.unwrap(); + assert!(!is_marked(&client, "k"), "a write is not trusted"); + for _ in 0..2 { + assert_eq!( + client.get::>("k").await.unwrap(), + Some(vec![1, 2, 3]) + ); + assert!(is_marked(&client, "k")); + } + } + + /// The mark records the walk, not the decode: a plaintext that passes the + /// walk and is read as the wrong type is marked, and the error stays. + #[tokio::test] + async fn a_walk_that_passes_marks_even_when_the_type_does_not_fit() { + let client = secure_client(&MemoryBackend::default()); + client.set("k", &7u32).await.unwrap(); + for _ in 0..2 { + assert!(matches!( + client.get::("k").await, + Err(crate::CachekitError::Serialization(_)) + )); + assert!(is_marked(&client, "k")); + } + assert_eq!(client.get::("k").await.unwrap(), Some(7)); + } + + #[tokio::test] + async fn an_l2_hit_marks_nothing_and_the_next_l1_hit_does() { + let backend = MemoryBackend::default(); + secure_client(&backend).set("k", &"v").await.unwrap(); + let client = secure_client(&backend); + assert_eq!( + client.get::("k").await.unwrap().as_deref(), + Some("v") + ); + assert_eq!(client.stats().l2_hits, 1); + assert!(!is_marked(&client, "k")); + assert_eq!( + client.get::("k").await.unwrap().as_deref(), + Some("v") + ); + assert_eq!(client.stats().l1_hits, 1); + assert!(is_marked(&client, "k")); + } + + /// A mark lands only on the entry its read returned: a write or a delete + /// between the read and the mark leaves L1 as that write or delete left it. + #[tokio::test] + async fn a_mark_never_lands_on_a_rewritten_or_deleted_entry() { + let client = secure_client(&MemoryBackend::default()); + let l1 = client.l1.as_ref().unwrap(); + client.set("k", &1u32).await.unwrap(); + let read = client.l1_get("k").unwrap(); + let encryption = client.encryption.as_deref().unwrap(); + let decrypted = read.decrypt(|ct| encryption.decrypt(ct, "k")).unwrap(); + assert!( + decrypted.marked().is_none(), + "nothing is marked before the walk" + ); + decrypted.deserializer().unwrap(); + let marked = decrypted.marked().expect("the walk passed"); + + client.set("k", &2u32).await.unwrap(); + l1.adopt("k", marked.clone()); + assert!(!is_marked(&client, "k"), "the rewrite stays unmarked"); + assert_eq!(client.get::("k").await.unwrap(), Some(2)); + + client.delete("k").await.unwrap(); + l1.adopt("k", marked); + assert!(client.l1_get("k").is_none(), "a mark adds no entry"); + assert_eq!(l1.entry_count(), 0); + } +} diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index c774c25..e1f57a3 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -485,6 +485,17 @@ pub(crate) fn deserialize_payload( decode(payload.deserializer()?) } +/// [`deserialize`] for decrypted plaintext: skips the walk when the +/// ciphertext's mark says it already passed (see +/// `crate::serializer::Decrypted::deserializer`). +#[cfg(feature = "encryption")] +pub(crate) fn deserialize_decrypted( + decrypted: &crate::serializer::Decrypted<'_>, +) -> Result { + reject_ck_frame(decrypted.plaintext())?; + decode(decrypted.deserializer()?) +} + fn reject_ck_frame(bytes: &[u8]) -> Result<(), CachekitError> { if bytes.starts_with(b"CK") { return Err(CachekitError::Serialization( diff --git a/crates/cachekit/src/l1/mod.rs b/crates/cachekit/src/l1/mod.rs index 15eeab3..5692c14 100644 --- a/crates/cachekit/src/l1/mod.rs +++ b/crates/cachekit/src/l1/mod.rs @@ -230,6 +230,27 @@ impl L1Cache { ); } + /// Put `marked`, a read's payload with the mark its decode just earned, + /// in place of `key`'s entry's payload, if that entry still holds the + /// same buffer. A rewrite holds another buffer and keeps its own mark; + /// after a delete or eviction there is no entry and none is added. The + /// entry's TTL and freshness window are unchanged; the swap counts as one + /// more access for eviction, as a read does. + #[cfg(feature = "encryption")] + pub(crate) fn adopt(&self, key: &str, marked: Payload) { + // Under the shard's write lock, so no write can land between the + // check and the swap. A zero timeout never waits on a placeholder; + // L1 inserts none, so only another `adopt` of an absent key holds + // one. On an absent key `entry` returns a placeholder guard, and + // dropping it uninserted removes the placeholder and nothing else. + let _ = self.store.entry(key, Some(Duration::ZERO), |_, entry| { + if entry.data.same_buffer(&marked) { + entry.data = marked; + } + quick_cache::sync::EntryAction::Retain(()) + }); + } + /// Remove an entry by key. pub fn delete(&self, key: &str) { self.store.remove(key); diff --git a/crates/cachekit/src/serializer/mod.rs b/crates/cachekit/src/serializer/mod.rs index 3e77eb3..874b75b 100644 --- a/crates/cachekit/src/serializer/mod.rs +++ b/crates/cachekit/src/serializer/mod.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "encryption")] +use std::cell::Cell; + use bytes::Bytes; use cachekit_core::msgpack_document_len; use rmp_serde::decode::ReadRefReader; @@ -22,7 +25,9 @@ use crate::error::CachekitError; /// runs before every decode in [`deserialize`] and /// [`crate::interop::deserialize`]. A client without encryption also runs it /// once when a backend read or its own write admits bytes, and a decode of -/// bytes that passed then skips it (see `Payload`). Depth +/// bytes that passed then skips it (see `Payload`). An encrypting client +/// runs it on decrypted plaintext until an L1 hit's plaintext passes, and +/// later hits on that L1 ciphertext skip it (see `Decrypted`). Depth /// counts every array and map header on the deepest path, empty ones included; /// scalars, str, bin and ext are leaves. /// Exactly `MAX_DECODE_DEPTH` levels are accepted, and one more is rejected @@ -37,8 +42,11 @@ pub(crate) type SliceDeserializer<'a> = rmp_serde::Deserializer SliceDeserializer<'_> { de } -/// Stored value bytes, and whether they already passed [`check_bounds`]. +/// Stored value bytes, and what about them already passed [`check_bounds`]. /// /// The walk guards a decode against bytes the backend controls, and a value /// this process wrote needs it too: rmp-serde's own depth limit does not count /// enum nesting, so a deep recursive enum would overflow the stack without it. -/// Bytes need the walk once, not on every read. [`Self::walk`] is the only way -/// to set the mark, and sets it only when the bytes pass, so a marked payload -/// decodes without walking again. L1 keeps the mark for the entry's lifetime. +/// Bytes need the walk once, not on every read. [`Self::walk`] and +/// [`Decrypted::marked`] are the only ways to mint a mark, and mint it only +/// when the walk passed. [`Self::deserializer`] honours only a +/// [`Walked::Bytes`] mark and [`Decrypted::deserializer`] only a +/// [`Walked::Plaintext`] one; either skips the walk on a mark it honours. +/// L1 keeps the mark for the entry's lifetime. #[derive(Clone, Debug)] pub(crate) struct Payload { - // Private, with `walked`: nothing outside `walk` can pair a mark with - // other bytes. + // Private, with `walked`: nothing outside this module can pair a mark + // with other bytes. bytes: Bytes, - walked: bool, + walked: Walked, +} + +/// What a [`Payload`]'s mark says passed [`check_bounds`]. Two kinds, so a +/// mark one kind of decode earned is never honoured by the other. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Walked { + /// Nothing yet: every decode walks. + No, + /// The stored bytes, on a client without encryption. + Bytes, + /// The plaintext the stored bytes, AES-GCM ciphertext, decrypt to. The + /// client's keyring is fixed when it is built and the AAD binds the key + /// the entry is stored under, so the same ciphertext read under the same + /// key fails authentication or yields the same plaintext: the walk's + /// verdict on it cannot change. Every read still decrypts. + #[cfg(feature = "encryption")] + Plaintext, } impl Payload { - /// Bytes every decode walks: ciphertext, whose decrypted plaintext is - /// walked instead, and bytes nothing has checked. + /// Bytes every decode walks: ciphertext not yet decrypted and walked, and + /// bytes nothing has checked. #[cfg_attr(not(any(feature = "l1", feature = "encryption")), allow(dead_code))] pub(crate) fn unwalked(bytes: Bytes) -> Self { Self { bytes, - walked: false, + walked: Walked::No, } } /// Walk `bytes` now and mark them only if they pass. A failure is not /// reported here; a decode walks the unmarked bytes again and reports it. pub(crate) fn walk(bytes: Bytes) -> Self { - let walked = check_bounds(&bytes).is_ok(); + let walked = if check_bounds(&bytes).is_ok() { + Walked::Bytes + } else { + Walked::No + }; Self { bytes, walked } } @@ -153,21 +185,97 @@ impl Payload { &self.bytes } + /// Whether `self` and `other` share one buffer. The address comparison is + /// sound while both are alive: each holds a reference, so the buffer + /// cannot be freed and its address reused. + #[cfg(all(feature = "l1", feature = "encryption"))] + pub(crate) fn same_buffer(&self, other: &Self) -> bool { + self.bytes.as_ptr() == other.bytes.as_ptr() && self.bytes.len() == other.bytes.len() + } + /// Whether the bytes passed the walk. #[cfg(all(test, feature = "l1", not(feature = "unsync")))] pub(crate) fn is_walked(&self) -> bool { - self.walked + self.walked == Walked::Bytes + } + + /// Whether the plaintext these bytes decrypt to passed the walk. + #[cfg(all(test, feature = "l1", feature = "encryption", not(feature = "unsync")))] + pub(crate) fn is_plaintext_walked(&self) -> bool { + self.walked == Walked::Plaintext } /// [`bounded_deserializer`] over the bytes, without the walk when they /// already passed it. pub(crate) fn deserializer(&self) -> Result, CachekitError> { - if self.walked { + if self.walked == Walked::Bytes { Ok(depth_limited(&self.bytes)) } else { bounded_deserializer(&self.bytes) } } + + /// Decrypt the bytes with `decrypt`, which must decrypt for the key this + /// payload was read under, keeping the plaintext with its ciphertext. + #[cfg(feature = "encryption")] + pub(crate) fn decrypt( + &self, + decrypt: impl FnOnce(&[u8]) -> Result, CachekitError>, + ) -> Result, CachekitError> { + Ok(Decrypted { + plaintext: decrypt(&self.bytes)?, + ciphertext: self, + passed: Cell::new(false), + }) + } +} + +/// The plaintext a ciphertext [`Payload`] decrypted to. +#[cfg(feature = "encryption")] +pub(crate) struct Decrypted<'a> { + plaintext: Vec, + ciphertext: &'a Payload, + /// [`Self::deserializer`] walked the plaintext and it passed. + passed: Cell, +} + +#[cfg(feature = "encryption")] +impl Decrypted<'_> { + /// The decrypted bytes. + pub(crate) fn plaintext(&self) -> &[u8] { + &self.plaintext + } + + /// [`bounded_deserializer`] over the plaintext, without the walk when the + /// ciphertext's mark says it already passed. + pub(crate) fn deserializer(&self) -> Result, CachekitError> { + if self.ciphertext.walked == Walked::Plaintext { + return Ok(depth_limited(&self.plaintext)); + } + let de = bounded_deserializer(&self.plaintext)?; + self.passed.set(true); + Ok(de) + } + + /// The ciphertext marked as decrypting to plaintext that passed the walk, + /// when [`Self::deserializer`] walked it here and it passed: for the + /// caller to put back in L1 (`L1Cache::adopt`). `None` otherwise. + #[cfg(feature = "l1")] + pub(crate) fn marked(&self) -> Option { + self.passed.get().then(|| Payload { + bytes: self.ciphertext.bytes.clone(), + walked: Walked::Plaintext, + }) + } +} + +/// [`deserialize`] for decrypted plaintext: skips the walk when the +/// ciphertext's mark says it already passed (see [`Decrypted::deserializer`]). +#[cfg(feature = "encryption")] +pub(crate) fn deserialize_decrypted( + decrypted: &Decrypted<'_>, +) -> Result { + decode(decrypted.deserializer()?) } /// [`deserialize`] for a payload a read returned: a marked payload skips the diff --git a/crates/cachekit/tests/common/mod.rs b/crates/cachekit/tests/common/mod.rs index 0623de9..d0dcc4a 100644 --- a/crates/cachekit/tests/common/mod.rs +++ b/crates/cachekit/tests/common/mod.rs @@ -93,13 +93,6 @@ impl Backend for MockBackend { /// One untrusted read: the path's name and what it decoded. pub type Read = (&'static str, Result); -/// One path read twice: from the backend, then from L1. -pub type ReadTwice = ( - &'static str, - Result, - Result, -); - /// Both untrusted decode entry points: auto-mode `get` and interop `interop_get`. pub fn decode_both(bytes: &[u8]) -> [Read; 2] { [ @@ -189,7 +182,7 @@ pub fn read_every_path(bytes: &[u8]) -> Vec> { reads } -/// The client reads that decode an L1 entry without decrypting it. +/// The client read paths an L1 hit can serve. #[cfg(feature = "l1")] pub const L1_READS: [&str; 5] = [ "CacheKit::get", @@ -230,41 +223,74 @@ async fn read_by( } } -/// Every [`L1_READS`] path of a client with L1 on, each reading a backend -/// entry holding exactly `bytes` twice under a key of its own. The first read -/// reaches the backend, which backfills L1 before anything decodes. The -/// backend entry is then removed, so the second read is served by L1 or it is -/// a miss, and a miss panics here. Returns `(path, first, second)`. +/// One [`L1_READS`] path read `N` times: from the backend, then from L1. #[cfg(feature = "l1")] -pub fn read_every_path_twice_through_l1(bytes: &[u8]) -> Vec> { - let runtime = runtime(); +pub type ReadsThroughL1 = (&'static str, [Result; N]); + +/// Every [`L1_READS`] path of a client with L1 on, each under a key of its +/// own whose backend entry is `stored(key)`. The first read reaches the +/// backend, which backfills L1 before anything decodes. The backend entry is +/// then removed, so the other `N - 1` reads are served by L1 or are misses, +/// and a miss panics here. +#[cfg(feature = "l1")] +fn read_every_path_through_l1( + builder: cachekit::CacheKitBuilder, + stored: impl Fn(&str) -> Vec, +) -> Vec> { let (backend, handle) = MockBackend::new_with_handle(); - let client = CacheKit::builder() + let client = builder .backend(backend) .l1_capacity(64) .build() .expect("client builds"); - let found = |r: Result, CachekitError>| { - r.map(|v| v.expect("the forged entry must be found, not missed")) - }; - runtime.block_on(async { + runtime().block_on(async { let mut reads = Vec::new(); for path in L1_READS { - let key = format!("forged:{path}"); - handle - .store - .lock() - .await - .insert(key.clone(), bytes.to_vec()); - let first = found(read_by::(&client, path, &key).await); - handle.store.lock().await.remove(&key); - let second = found(read_by::(&client, path, &key).await); - reads.push((path, first, second)); + let key = format!("stored:{path}"); + handle.store.lock().await.insert(key.clone(), stored(&key)); + let mut results = Vec::with_capacity(N); + for _ in 0..N { + let read = read_by::(&client, path, &key).await; + results.push(read.map(|v| v.expect("the stored entry must be found, not missed"))); + handle.store.lock().await.remove(&key); + } + let Ok(results) = results.try_into() else { + unreachable!("N reads were pushed"); + }; + reads.push((path, results)); } reads }) } +/// [`read_every_path_through_l1`] reading a backend entry holding exactly +/// `bytes` twice: from the backend, then from L1. +#[cfg(feature = "l1")] +pub fn read_every_path_twice_through_l1( + bytes: &[u8], +) -> Vec> { + read_every_path_through_l1(CacheKit::builder(), |_| bytes.to_vec()) +} + +/// [`read_every_path_through_l1`] on an encrypting client, whose backend +/// entry is `plaintext` sealed under the client's own key for the entry's +/// key, so it passes authentication and only the decode can refuse it. Each +/// path reads it from the backend, then twice from L1: the first L1 hit +/// walks the plaintext, and the second skips the walk only if the first +/// marked the entry. +#[cfg(all(feature = "l1", feature = "encryption"))] +pub fn read_every_path_sealed_through_l1( + plaintext: &[u8], +) -> Vec> { + const KEY: [u8; 32] = [0x42; 32]; + const TENANT: &str = "sealed-reads"; + let layer = cachekit::EncryptionLayer::new(&KEY, TENANT).expect("key and tenant"); + let builder = CacheKit::builder() + .encryption_from_bytes(&KEY, TENANT) + .expect("key and tenant"); + read_every_path_through_l1(builder, |key| layer.encrypt(plaintext, key).expect("seal")) +} + /// An encrypting client, L1 off so every read reaches the backend, holding /// `bytes` at the backend key `stored_key`. #[cfg(feature = "encryption")] diff --git a/crates/cachekit/tests/decode_bounds_tests.rs b/crates/cachekit/tests/decode_bounds_tests.rs index 5f6776c..fe1724f 100644 --- a/crates/cachekit/tests/decode_bounds_tests.rs +++ b/crates/cachekit/tests/decode_bounds_tests.rs @@ -156,7 +156,7 @@ fn a_hostile_entry_fails_from_the_backend_and_again_from_l1() { for vector in vectors()["reject_vectors"].as_array().unwrap() { let name = vector["name"].as_str().unwrap(); let bytes = unhex(vector["input_hex"].as_str().unwrap()); - for (path, first, second) in read_every_path_twice_through_l1::(&bytes) { + for (path, [first, second]) in read_every_path_twice_through_l1::(&bytes) { assert_guard_rejected(name, &format!("{path} (backend)"), first); assert_guard_rejected(name, &format!("{path} (L1)"), second); } @@ -173,7 +173,7 @@ fn every_accept_vector_decodes_from_the_backend_and_from_l1() { for vector in vectors()["accept_vectors"].as_array().unwrap() { let name = vector["name"].as_str().unwrap(); let bytes = unhex(vector["input_hex"].as_str().unwrap()); - for (path, first, second) in read_every_path_twice_through_l1::(&bytes) { + for (path, [first, second]) in read_every_path_twice_through_l1::(&bytes) { let first = first.unwrap_or_else(|e| panic!("{name}: {path} (backend): {e}")); let second = second.unwrap_or_else(|e| panic!("{name}: {path} (L1): {e}")); assert_eq!(first, second, "{name}: {path}"); @@ -190,23 +190,14 @@ fn every_accept_vector_decodes_from_the_backend_and_from_l1() { #[cfg(feature = "l1")] #[test] fn an_own_write_of_a_deep_recursive_enum_fails_from_l1() { - #[derive(serde::Serialize, Deserialize)] - enum Node { - Leaf(i64), - List(Vec), - } on_default_stack(|| { let client = cachekit::CacheKit::builder() .backend(common::MockBackend::shared()) .l1_capacity(16) .build() .expect("client builds"); - let mut value = Node::Leaf(0); - for _ in 0..60 { - value = Node::List(vec![value]); - } common::runtime().block_on(async { - client.set("enum", &value).await.expect("set"); + client.set("enum", &deep_enum()).await.expect("set"); let read = client .get::("enum") .await @@ -218,6 +209,109 @@ fn an_own_write_of_a_deep_recursive_enum_fails_from_l1() { }); } +/// `List` nested 60 deep: 120 MessagePack levels, which the walk rejects and +/// rmp-serde alone would decode (see the test above). +#[cfg(feature = "l1")] +#[derive(serde::Serialize, Deserialize)] +enum Node { + Leaf(i64), + List(Vec), +} + +#[cfg(feature = "l1")] +fn deep_enum() -> Node { + let mut value = Node::Leaf(0); + for _ in 0..60 { + value = Node::List(vec![value]); + } + value +} + +/// An encrypting client's first L1 hit whose decrypted plaintext passes the +/// walk marks the entry, and later hits skip the walk. Hostile plaintext +/// under a valid tag must never be marked: the read from the backend fails, +/// and so does every read L1 serves after it. +/// +/// The secure path replaces the decoder's message, so a reject vector shows +/// only that each read failed. The recursive enum is the case a wrong mark +/// would let through: without the walk it decodes. +#[cfg(all(feature = "l1", feature = "encryption"))] +#[test] +fn sealed_hostile_plaintext_fails_from_the_backend_and_every_time_from_l1() { + on_default_stack(|| { + let mut cases: Vec<(String, Vec)> = vectors()["reject_vectors"] + .as_array() + .unwrap() + .iter() + .map(|v| { + let name = v["name"].as_str().unwrap().to_owned(); + (name, unhex(v["input_hex"].as_str().unwrap())) + }) + .collect(); + cases.push(( + "deep recursive enum".to_owned(), + serializer::serialize(&deep_enum()).unwrap(), + )); + for (name, plaintext) in cases { + let reads = common::read_every_path_sealed_through_l1::(&plaintext); + for (path, reads) in reads { + for (layer, read) in ["backend", "L1", "L1 again"].into_iter().zip(reads) { + assert!( + matches!(read, Err(CachekitError::Serialization(_))), + "{name}: {path} ({layer}) must refuse sealed hostile plaintext" + ); + } + } + } + }); +} + +/// A sealed accept vector decodes the same from the backend, from the L1 hit +/// that walks and marks it, and from the marked hit after it. +#[cfg(all(feature = "l1", feature = "encryption"))] +#[test] +fn every_sealed_accept_vector_decodes_from_the_backend_and_from_l1() { + on_default_stack(|| { + for vector in vectors()["accept_vectors"].as_array().unwrap() { + let name = vector["name"].as_str().unwrap(); + let bytes = unhex(vector["input_hex"].as_str().unwrap()); + for (path, reads) in common::read_every_path_sealed_through_l1::(&bytes) { + let [backend, l1, l1_again] = + reads.map(|r| r.unwrap_or_else(|e| panic!("{name}: {path}: {e}"))); + assert_eq!(backend, l1, "{name}: {path}"); + assert_eq!(l1, l1_again, "{name}: {path}"); + } + } + }); +} + +/// The encrypting twin of `an_own_write_of_a_deep_recursive_enum_fails_from_l1`: +/// an own write is not trusted either, so every L1 hit on it walks and fails. +#[cfg(all(feature = "l1", feature = "encryption"))] +#[test] +fn an_own_encrypted_write_of_a_deep_recursive_enum_fails_from_l1_every_time() { + on_default_stack(|| { + let client = cachekit::CacheKit::builder() + .backend(common::MockBackend::shared()) + .l1_capacity(16) + .encryption_from_bytes(&[0x42; 32], "own-write") + .expect("key and tenant") + .build() + .expect("client builds"); + common::runtime().block_on(async { + client.set("enum", &deep_enum()).await.expect("set"); + for hit in 1..=2 { + let read = client.get::("enum").await; + assert_eq!(client.stats().l1_hits, hit, "L1 served it"); + assert!( + matches!(read, Err(CachekitError::Serialization(_))), + "L1 hit {hit} must refuse the deep enum" + ); + } + }); + }); +} + #[test] fn every_accept_vector_decodes_on_every_read_path() { on_default_stack(|| {