Skip to main content

cachekit/
encryption.rs

1//! Zero-knowledge encryption layer using AES-256-GCM with AAD v0x03 format.
2//!
3//! Wraps `cachekit_core::ZeroKnowledgeEncryptor` with tenant key derivation
4//! and cache-key-bound Additional Authenticated Data (AAD). The AAD binding
5//! prevents ciphertext substitution attacks within the same tenant (CVSS 8.5).
6//!
7//! # AAD v0x03 Format
8//!
9//! ```text
10//! [version(0x03)][len(4)][tenant_id][len(4)][cache_key][len(4)][format][len(4)][compressed]
11//! ```
12//!
13//! Each component is length-prefixed with a 4-byte big-endian u32 to prevent
14//! collision attacks from boundary confusion.
15
16use zeroize::Zeroizing;
17
18use cachekit_core::ZeroKnowledgeEncryptor;
19
20use crate::error::CachekitError;
21
22/// AAD protocol version byte.
23const AAD_VERSION: u8 = 0x03;
24
25/// Zero-knowledge encryption layer with per-tenant key derivation.
26///
27/// Holds a derived encryption key (zeroized on drop) and the
28/// `ZeroKnowledgeEncryptor` from cachekit-core for AES-256-GCM operations.
29///
30/// L1 stores **ciphertext**, not plaintext — the zero-knowledge property
31/// is preserved across all cache layers.
32pub struct EncryptionLayer {
33    encryptor: ZeroKnowledgeEncryptor,
34    derived_key: Zeroizing<[u8; 32]>,
35    tenant_id: String,
36}
37
38impl EncryptionLayer {
39    /// Create a new encryption layer with HKDF-derived tenant keys.
40    ///
41    /// # Arguments
42    /// * `master_key_bytes` — Raw master key (minimum 32 bytes for AES-256)
43    /// * `tenant_id` — Tenant identifier for cryptographic isolation
44    ///
45    /// # Errors
46    /// - Master key too short (< 32 bytes)
47    /// - HKDF derivation failure
48    /// - Encryptor initialization failure
49    pub fn new(master_key_bytes: &[u8], tenant_id: &str) -> Result<Self, CachekitError> {
50        if master_key_bytes.len() < 32 {
51            return Err(CachekitError::Encryption(format!(
52                "master key must be at least 32 bytes; got {}",
53                master_key_bytes.len()
54            )));
55        }
56        if tenant_id.is_empty() {
57            return Err(CachekitError::Encryption(
58                "tenant_id must not be empty".to_owned(),
59            ));
60        }
61        if tenant_id.len() > 255 {
62            return Err(CachekitError::Encryption(format!(
63                "tenant_id must be at most 255 bytes; got {}",
64                tenant_id.len()
65            )));
66        }
67
68        let tenant_keys = cachekit_core::encryption::key_derivation::derive_tenant_keys(
69            master_key_bytes,
70            tenant_id,
71        )
72        .map_err(|e| CachekitError::Encryption(format!("key derivation failed: {e}")))?;
73
74        let encryptor = ZeroKnowledgeEncryptor::new()
75            .map_err(|e| CachekitError::Encryption(format!("encryptor init failed: {e}")))?;
76
77        Ok(Self {
78            encryptor,
79            derived_key: Zeroizing::new(tenant_keys.encryption_key),
80            tenant_id: tenant_id.to_owned(),
81        })
82    }
83
84    /// Encrypt plaintext with AAD bound to the cache key.
85    ///
86    /// Output format: `[nonce(12)][ciphertext + auth_tag(16)]`
87    pub fn encrypt(&self, plaintext: &[u8], cache_key: &str) -> Result<Vec<u8>, CachekitError> {
88        // compressed=false is normative, not a stub — see build_aad's invariant note.
89        let aad = self.build_aad(cache_key, false);
90        self.encryptor
91            .encrypt_aes_gcm(plaintext, &*self.derived_key, &aad)
92            .map_err(|e| CachekitError::Encryption(format!("encrypt failed: {e}")))
93    }
94
95    /// Decrypt ciphertext with AAD bound to the cache key.
96    ///
97    /// Returns the original plaintext. Fails if the cache key does not match
98    /// the one used during encryption (ciphertext substitution protection).
99    pub fn decrypt(&self, ciphertext: &[u8], cache_key: &str) -> Result<Vec<u8>, CachekitError> {
100        // compressed=false is normative, not a stub — see build_aad's invariant note.
101        let aad = self.build_aad(cache_key, false);
102        self.encryptor
103            .decrypt_aes_gcm(ciphertext, &*self.derived_key, &aad)
104            .map_err(|e| CachekitError::Encryption(format!("decrypt failed: {e}")))
105    }
106
107    /// Return the tenant ID used for key derivation.
108    pub fn tenant_id(&self) -> &str {
109        &self.tenant_id
110    }
111
112    /// Build AAD v0x03 for a given cache key and compression flag.
113    ///
114    /// Format: `[0x03][len][tenant_id][len][cache_key][len]["msgpack"][len]["True"/"False"]`
115    ///
116    /// All lengths are 4-byte big-endian u32 to prevent boundary-confusion attacks.
117    ///
118    /// # Invariant: `compressed` is always `false` in production
119    ///
120    /// `encrypt` and `decrypt` pass the literal `false`, and that is normative, not a
121    /// gap: this SDK's only cross-SDK encrypted surface is interop mode, and
122    /// `protocol/spec/interop-mode.md` ("Encryption in Interop Mode") mandates the AAD
123    /// components `format = "msgpack"`, `compressed = "False"` — there is no
124    /// compression in interop mode.
125    ///
126    /// Why a reader cannot recover from a wrong flag here: in the general flow a reader
127    /// rebuilds the AAD from the **stored cleartext metadata** written alongside the
128    /// ciphertext (`protocol/spec/encryption.md`, encrypt step 6 / decrypt step 3).
129    /// cachekit-rs stores no such per-entry metadata — it writes plain
130    /// `nonce ‖ ciphertext ‖ tag` with no header — so its readers have nothing to read
131    /// the flag from and must reconstruct the AAD from the interop-pinned constants
132    /// alone. Combined with the no-retry rule (a reader MUST NOT retry decryption with
133    /// any alternative AAD input, `protocol/spec/encryption.md`), emitting
134    /// `compressed = "True"` here would produce ciphertext that no conformant peer can
135    /// authenticate, and none may probe for.
136    ///
137    /// So do not flip this literal to thread a live compression flag through
138    /// `encrypt`/`decrypt`. If this SDK ever does compress, the flag must be *stored*
139    /// per entry and threaded from that stored value — `protocol/spec/encryption.md`
140    /// requires `compressed` to describe the actual plaintext, and claiming `"False"`
141    /// over compressed bytes is the cachekit-py#166 conformance bug (round-trips
142    /// in-process, fails authentication for every correct second reader). A compressed
143    /// cross-SDK profile is a versioned protocol change (interop/v2, Multica LAB-1135),
144    /// not an SDK flag.
145    ///
146    /// The `"True"`/`"False"` tokens are frozen byte-level protocol constants —
147    /// normative byte table in `protocol/spec/encryption.md`, section
148    /// "`compressed` tokens"; decided in
149    /// [protocol#12](https://github.com/cachekit-io/protocol/issues/12) (resolved
150    /// 2026-07-19: a spec correction, not a wire change). The `true` branch stays,
151    /// exercised by conformance tests, so those bytes remain pinned.
152    pub fn build_aad(&self, cache_key: &str, compressed: bool) -> Vec<u8> {
153        let format_str = b"msgpack";
154        let compressed_str = if compressed {
155            b"True" as &[u8]
156        } else {
157            b"False"
158        };
159
160        let tenant_bytes = self.tenant_id.as_bytes();
161        let key_bytes = cache_key.as_bytes();
162
163        // Pre-allocate: version(1) + 4 length fields(16) + data
164        let capacity =
165            1 + 16 + tenant_bytes.len() + key_bytes.len() + format_str.len() + compressed_str.len();
166        let mut aad = Vec::with_capacity(capacity);
167
168        aad.push(AAD_VERSION);
169
170        // All components are bounded: tenant_id <= 255 (validated in new()),
171        // cache_key <= 1024 (validated by client), format/compressed are constants.
172        // Safe to use len_u32 helper which saturates on overflow.
173
174        // tenant_id
175        aad.extend_from_slice(&len_u32(tenant_bytes.len()).to_be_bytes());
176        aad.extend_from_slice(tenant_bytes);
177
178        // cache_key
179        aad.extend_from_slice(&len_u32(key_bytes.len()).to_be_bytes());
180        aad.extend_from_slice(key_bytes);
181
182        // format
183        aad.extend_from_slice(&len_u32(format_str.len()).to_be_bytes());
184        aad.extend_from_slice(format_str);
185
186        // compressed flag
187        aad.extend_from_slice(&len_u32(compressed_str.len()).to_be_bytes());
188        aad.extend_from_slice(compressed_str);
189
190        aad
191    }
192}
193
194/// Convert a usize length to u32 for AAD encoding, saturating on overflow.
195/// In practice all inputs are validated to fit (tenant_id <= 255, cache_key <= 1024).
196#[allow(clippy::cast_possible_truncation)]
197fn len_u32(len: usize) -> u32 {
198    u32::try_from(len).unwrap_or(u32::MAX)
199}
200
201impl std::fmt::Debug for EncryptionLayer {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.debug_struct("EncryptionLayer")
204            .field("tenant_id", &self.tenant_id)
205            .field("derived_key", &"[REDACTED]")
206            .finish()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    const TEST_MASTER_KEY: &[u8] = b"test_master_key_32_bytes_long!!!";
215    const TEST_TENANT: &str = "test-tenant";
216
217    #[test]
218    fn roundtrip_encrypt_decrypt() {
219        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
220        let plaintext = b"hello, zero-knowledge world";
221
222        let ciphertext = layer.encrypt(plaintext, "my:key").unwrap();
223        let decrypted = layer.decrypt(&ciphertext, "my:key").unwrap();
224
225        assert_eq!(decrypted, plaintext);
226    }
227
228    #[test]
229    fn wrong_cache_key_fails_decryption() {
230        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
231        let ciphertext = layer.encrypt(b"secret", "key:a").unwrap();
232
233        let result = layer.decrypt(&ciphertext, "key:b");
234        assert!(result.is_err(), "decryption with wrong cache key must fail");
235    }
236
237    #[test]
238    fn different_tenants_produce_different_ciphertext() {
239        let layer_a = EncryptionLayer::new(TEST_MASTER_KEY, "tenant-a").unwrap();
240        let layer_b = EncryptionLayer::new(TEST_MASTER_KEY, "tenant-b").unwrap();
241
242        let ct_a = layer_a.encrypt(b"same data", "same:key").unwrap();
243        let ct_b = layer_b.encrypt(b"same data", "same:key").unwrap();
244
245        // Nonces differ, so ciphertext differs, but also keys differ
246        assert_ne!(ct_a, ct_b);
247
248        // Cross-tenant decryption must fail
249        assert!(layer_b.decrypt(&ct_a, "same:key").is_err());
250    }
251
252    #[test]
253    fn master_key_too_short() {
254        let result = EncryptionLayer::new(b"short", "tenant");
255        assert!(result.is_err());
256        let msg = result.unwrap_err().to_string();
257        assert!(msg.contains("at least 32 bytes"), "got: {msg}");
258    }
259
260    #[test]
261    fn aad_v03_format() {
262        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
263        let aad = layer.build_aad("user:42", false);
264
265        // Version byte
266        assert_eq!(aad[0], 0x03);
267
268        // tenant_id length (4 bytes BE) + tenant_id
269        let tenant_len = u32::from_be_bytes(aad[1..5].try_into().unwrap()) as usize;
270        assert_eq!(tenant_len, TEST_TENANT.len());
271        assert_eq!(&aad[5..5 + tenant_len], TEST_TENANT.as_bytes());
272
273        // cache_key length + cache_key
274        let offset = 5 + tenant_len;
275        let key_len = u32::from_be_bytes(aad[offset..offset + 4].try_into().unwrap()) as usize;
276        assert_eq!(key_len, 7); // "user:42"
277        assert_eq!(&aad[offset + 4..offset + 4 + key_len], b"user:42");
278
279        // format length + format
280        let offset = offset + 4 + key_len;
281        let fmt_len = u32::from_be_bytes(aad[offset..offset + 4].try_into().unwrap()) as usize;
282        assert_eq!(&aad[offset + 4..offset + 4 + fmt_len], b"msgpack");
283
284        // compressed length + compressed
285        let offset = offset + 4 + fmt_len;
286        let comp_len = u32::from_be_bytes(aad[offset..offset + 4].try_into().unwrap()) as usize;
287        assert_eq!(&aad[offset + 4..offset + 4 + comp_len], b"False");
288    }
289
290    #[test]
291    fn aad_compressed_flag() {
292        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
293        let aad_false = layer.build_aad("k", false);
294        let aad_true = layer.build_aad("k", true);
295
296        assert_ne!(aad_false, aad_true);
297        // "True" is at the end
298        assert!(aad_true.ends_with(b"True"));
299        assert!(aad_false.ends_with(b"False"));
300    }
301
302    #[test]
303    fn debug_redacts_key() {
304        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
305        let debug = format!("{layer:?}");
306        assert!(debug.contains("[REDACTED]"));
307        assert!(!debug.contains("test_master_key"));
308    }
309}