pub struct EncryptionLayer { /* private fields */ }Expand description
Zero-knowledge encryption layer with per-tenant key derivation.
Holds a derived encryption key (zeroized on drop), a Keyring of master
keys for multi-key decrypt during rotation, and the
ZeroKnowledgeEncryptor from cachekit-core for AES-256-GCM operations.
L1 stores ciphertext, not plaintext — the zero-knowledge property is preserved across all cache layers.
§Key rotation
Writes always encrypt under the current master key. Reads decrypt via the
keyring: sequential attempts, current key first, then each decrypt-only
previous key in order, rebuilding the identical AAD per attempt
(cachekit-rs entries carry no per-entry key identity — sequential
attempts are the spec-assigned branch, protocol/spec/encryption.md →
“Key Rotation (Keyring)”). Reads served by a previous key are counted —
see Self::previous_key_hits for the rotation drain workflow.
Implementations§
Source§impl EncryptionLayer
impl EncryptionLayer
Sourcepub fn new(
master_key_bytes: &[u8],
tenant_id: &str,
) -> Result<Self, CachekitError>
pub fn new( master_key_bytes: &[u8], tenant_id: &str, ) -> Result<Self, CachekitError>
Create a new encryption layer with HKDF-derived tenant keys.
Equivalent to Self::with_previous_keys with no previous keys.
§Arguments
master_key_bytes— Raw master key, exactly 32 bytestenant_id— Tenant identifier for cryptographic isolation
§Errors
- Master key not exactly 32 bytes (
CachekitError::Config) - HKDF derivation failure
- Encryptor initialization failure
Sourcepub fn with_previous_keys(
master_key_bytes: &[u8],
previous_keys: &[&[u8]],
tenant_id: &str,
) -> Result<Self, CachekitError>
pub fn with_previous_keys( master_key_bytes: &[u8], previous_keys: &[&[u8]], tenant_id: &str, ) -> Result<Self, CachekitError>
Create an encryption layer with decrypt-only previous master keys.
previous_keys are retained during a key-rotation grace window, in
attempt order. Writes always use master_key_bytes; reads attempt it
first, then each previous key sequentially.
Every key, current and previous, must be exactly 32 raw bytes —
the only length every CacheKit SDK accepts. Pass the decoded key, never
the ASCII bytes of a hex string (64 bytes, which would derive a key no
other SDK derives); hex keys go through
CacheKitBuilder::encryption_with_previous,
the hex counterpart for rotation.
§Errors
- Any key not exactly 32 bytes (
CachekitError::Config) - More than 3 previous keys (
CachekitError::Config— rejected, never truncated) - The current master key also present in
previous_keys(CachekitError::Config— rotation is forward-only) - HKDF derivation failure
- Encryptor initialization failure
Sourcepub fn encrypt(
&self,
plaintext: &[u8],
cache_key: &str,
) -> Result<Vec<u8>, CachekitError>
pub fn encrypt( &self, plaintext: &[u8], cache_key: &str, ) -> Result<Vec<u8>, CachekitError>
Encrypt plaintext with AAD bound to the cache key.
Output format: [nonce(12)][ciphertext + auth_tag(16)]
Sourcepub fn decrypt(
&self,
ciphertext: &[u8],
cache_key: &str,
) -> Result<Vec<u8>, CachekitError>
pub fn decrypt( &self, ciphertext: &[u8], cache_key: &str, ) -> Result<Vec<u8>, CachekitError>
Decrypt ciphertext with AAD bound to the cache key.
Returns the original plaintext. Fails if the cache key does not match the one used during encryption (ciphertext substitution protection).
Decryption goes through the keyring: the current master key is attempted first, then each decrypt-only previous key in order, with the identical AAD per attempt. Entries written before a key rotation stay readable as long as their key remains in the previous list.
A read served by a previous key is counted in
Self::previous_key_hits (the rotation drain signal).
Sourcepub fn previous_key_hits(&self) -> Vec<u64>
pub fn previous_key_hits(&self) -> Vec<u64>
Reads decrypted by each previous key, by position in the previous-key
list (hits[i] ↔ previous_keys[i]); empty when there are none.
Reads served by the current key are not counted.
This is the rotation drain signal. It confirms that a grace window
has drained; it does not shorten one. Follow the protocol’s
scheduled-rotation runbook:
audit for non-expiring entries first, add the incoming key as
decrypt-only fleet-wide, then promote it. The window clock starts only
when that promotion deploy has completed on every instance — until
then a lagging instance still writes fresh ciphertext under the
retiring key and reads it silently as its current key (index 0).
From that point, wait at least the longest TTL in use (per-entry TTLs
passed to set_with_ttl count, not just the default), aggregating
counts across every instance — they are per process and reset on
restart. Only when the retiring key’s count has stayed flat over that
whole window has every live entry aged out or been re-encrypted on
write, and the key can be dropped from the previous list without a
hard cut-over. Positions are comparable across instances only once
they all run the same keyring configuration. The signal carries no
key material — positions and counts only.
use cachekit::EncryptionLayer;
let k1 = [0x11u8; 32]; // retiring master key
let k2 = [0x22u8; 32]; // current master key after rotation
// Encrypted under k1, before the rotation...
let ciphertext = EncryptionLayer::new(&k1, "tenant-123")?.encrypt(b"cached value", "user:1")?;
// ...a layer [current=k2, previous=[k1]] serves it from previous[0]:
// the retiring key is still draining, not yet safe to drop.
let layer = EncryptionLayer::with_previous_keys(&k2, &[&k1], "tenant-123")?;
assert_eq!(layer.previous_key_hits(), vec![0]);
assert_eq!(layer.decrypt(&ciphertext, "user:1")?, b"cached value");
assert_eq!(layer.previous_key_hits(), vec![1]);Sourcepub fn hardware_acceleration_enabled(&self) -> bool
pub fn hardware_acceleration_enabled(&self) -> bool
Whether cachekit-core detected AES hardware support on this host.
Forwards ZeroKnowledgeEncryptor::hardware_acceleration_enabled().
Informational only — ring/aes-gcm dispatch independently of it; the
per-architecture semantics are core’s (README → Zero-Knowledge
Encryption).
Sourcepub fn build_aad(&self, cache_key: &str, compressed: bool) -> Vec<u8> ⓘ
pub fn build_aad(&self, cache_key: &str, compressed: bool) -> Vec<u8> ⓘ
Build AAD v0x03 for a given cache key and compression flag.
Format: [0x03][len][tenant_id][len][cache_key][len]["msgpack"][len]["True"/"False"]
All lengths are 4-byte big-endian u32 to prevent boundary-confusion attacks.
§Invariant: compressed is always false in production
encrypt and decrypt pass the literal false, and that is normative, not a
gap: this SDK’s only cross-SDK encrypted surface is interop mode, and
protocol/spec/interop-mode.md (“Encryption in Interop Mode”) mandates the AAD
components format = "msgpack", compressed = "False" — there is no
compression in interop mode.
Why a reader cannot recover from a wrong flag here: in the general flow a reader
rebuilds the AAD from the stored cleartext metadata written alongside the
ciphertext (protocol/spec/encryption.md, encrypt step 6 / decrypt step 3).
cachekit-rs stores no such per-entry metadata — it writes plain
nonce ‖ ciphertext ‖ tag with no header — so its readers have nothing to read
the flag from and must reconstruct the AAD from the interop-pinned constants
alone. Combined with the no-retry rule (a reader MUST NOT retry decryption with
any alternative AAD input, protocol/spec/encryption.md), emitting
compressed = "True" here would produce ciphertext that no conformant peer can
authenticate, and none may probe for.
So do not flip this literal to thread a live compression flag through
encrypt/decrypt. If this SDK ever does compress, the flag must be stored
per entry and threaded from that stored value — protocol/spec/encryption.md
requires compressed to describe the actual plaintext, and claiming "False"
over compressed bytes is the cachekit-py#166 conformance bug (round-trips
in-process, fails authentication for every correct second reader). A compressed
cross-SDK profile is a versioned protocol change (interop/v2),
not an SDK flag.
The "True"/"False" tokens are frozen byte-level protocol constants —
normative byte table in protocol/spec/encryption.md, section
“compressed tokens”; decided in
protocol#12 (resolved
2026-07-19: a spec correction, not a wire change). The true branch stays,
exercised by conformance tests, so those bytes remain pinned.
Trait Implementations§
Auto Trait Implementations§
impl !Freeze for EncryptionLayer
impl RefUnwindSafe for EncryptionLayer
impl Send for EncryptionLayer
impl Sync for EncryptionLayer
impl Unpin for EncryptionLayer
impl UnsafeUnpin for EncryptionLayer
impl UnwindSafe for EncryptionLayer
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more