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}