cachekit-core 0.5.0

LZ4 compression, xxHash3 integrity, AES-256-GCM encryption for byte payloads
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Multi-key decrypt keyring for master-key rotation
//!
//! Implements the client-side keyring from the protocol spec
//! (`spec/encryption.md` → "Key Rotation (Keyring)", decision record
//! `decisions/key-rotation.md`): one **current** master key that encrypts and
//! decrypts, plus an ordered list of at most [`MAX_DECRYPT_ONLY_KEYS`]
//! **decrypt-only** master keys retained during a rotation grace window.
//!
//! Rotation state is configuration, not a state machine: writes always use the
//! current key; reads attempt keyring keys sequentially, current first, with
//! identical AAD per attempt. Old-key entries age out via TTL or re-encrypt on
//! the next write. Nothing on the wire changes — the ciphertext format and AAD
//! carry no key identity.
//!
//! All master-key material held by the keyring zeroizes on drop, decrypt-only
//! entries included, so SDK bindings can keep every keyring key behind the
//! native boundary.

use zeroize::{Zeroize, ZeroizeOnDrop};

use super::core::{EncryptionError, ZeroKnowledgeEncryptor};
use super::key_derivation::{derive_domain_key, key_fingerprint};
use super::KeyDomain;

/// Maximum number of decrypt-only keys a keyring accepts.
///
/// Bounds worst-case sequential decrypt attempts and resident key material
/// while still allowing a forced mid-window second rotation (e.g. an
/// offboarding landing during a long-TTL compliance window). Exceeding the cap
/// is a configuration error, rejected at construction — never truncated.
pub const MAX_DECRYPT_ONLY_KEYS: usize = 3;

/// A master-key keyring: one current key plus decrypt-only previous keys.
///
/// Each entry independently derives per-tenant keys via the crate's HKDF
/// construction ([`derive_domain_key`]); salts, domains, and fingerprints are
/// unchanged from single-key operation. Entry order is current key first, then
/// the decrypt-only keys in the order supplied.
///
/// # Invariants (enforced at construction)
///
/// - At most [`MAX_DECRYPT_ONLY_KEYS`] decrypt-only keys
///   ([`EncryptionError::KeyringCapExceeded`]).
/// - The current key must not appear in the decrypt-only list — the detectable
///   subset of the forward-only rule: a key that ever occupied the encrypting
///   slot is never re-promoted, because that would resume a used, unknowable
///   AES-GCM nonce budget ([`EncryptionError::CurrentKeyInDecryptOnlyList`]).
/// - Every key is at least 16 bytes
///   ([`EncryptionError::InvalidMasterKeyLength`]).
///
/// # Examples
///
/// A value encrypted under a retiring key stays readable through rotation as
/// long as that key remains in the decrypt-only list:
///
/// ```
/// use cachekit_core::{derive_domain_key, Keyring, ZeroKnowledgeEncryptor};
///
/// let k1 = [0x11u8; 32]; // retiring master key
/// let k2 = [0x22u8; 32]; // current master key after rotation
/// let encryptor = ZeroKnowledgeEncryptor::new()?;
///
/// // Encrypted under k1, before the rotation...
/// let tenant_key = derive_domain_key(&k1, "encryption", b"tenant-123")?;
/// let ciphertext = encryptor.encrypt_aes_gcm(b"cached value", &tenant_key, b"aad")?;
///
/// // ...still decrypts with keyring [current=k2, decrypt-only=[k1]].
/// let keyring = Keyring::new(&k2, &[&k1])?;
/// let plaintext = keyring.decrypt(&encryptor, &ciphertext, "tenant-123", b"aad")?;
/// assert_eq!(plaintext, b"cached value");
///
/// // A hard cut-over (empty decrypt-only list) cannot read the old entry.
/// let cut_over = Keyring::new(&k2, &[])?;
/// assert!(cut_over.decrypt(&encryptor, &ciphertext, "tenant-123", b"aad").is_err());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct Keyring {
    current: Vec<u8>,
    decrypt_only: Vec<Vec<u8>>,
}

impl Keyring {
    /// Create a keyring from a current master key and decrypt-only master keys.
    ///
    /// `decrypt_only` is ordered: on sequential decrypt, keys are attempted
    /// current first, then in the order given here.
    ///
    /// # Errors
    ///
    /// - [`EncryptionError::KeyringCapExceeded`] if more than
    ///   [`MAX_DECRYPT_ONLY_KEYS`] decrypt-only keys are supplied.
    /// - [`EncryptionError::CurrentKeyInDecryptOnlyList`] if the current key
    ///   also appears in the decrypt-only list.
    /// - [`EncryptionError::InvalidMasterKeyLength`] if any key is shorter
    ///   than 16 bytes.
    pub fn new(current: &[u8], decrypt_only: &[&[u8]]) -> Result<Self, EncryptionError> {
        if decrypt_only.len() > MAX_DECRYPT_ONLY_KEYS {
            return Err(EncryptionError::KeyringCapExceeded(decrypt_only.len()));
        }

        for key in std::iter::once(current).chain(decrypt_only.iter().copied()) {
            if key.len() < 16 {
                return Err(EncryptionError::InvalidMasterKeyLength(key.len()));
            }
        }

        // Plain equality is fine here: both operands are operator-supplied
        // configuration this process already holds, so there is no timing
        // oracle — this is config validation, not a secret comparison.
        if decrypt_only.contains(&current) {
            return Err(EncryptionError::CurrentKeyInDecryptOnlyList);
        }

        Ok(Self {
            current: current.to_vec(),
            decrypt_only: decrypt_only.iter().map(|key| key.to_vec()).collect(),
        })
    }

    /// Total number of keyring entries (1 current + decrypt-only keys).
    ///
    /// Private on purpose: bindings that need the count get it as
    /// `encryption_fingerprints().len()`, which they must fetch for selection
    /// anyway.
    fn entry_count(&self) -> usize {
        1 + self.decrypt_only.len()
    }

    /// Keyring entries in attempt order: current key first.
    fn entries(&self) -> impl Iterator<Item = &[u8]> {
        std::iter::once(self.current.as_slice())
            .chain(self.decrypt_only.iter().map(|key| key.as_slice()))
    }

    /// Per-entry fingerprints of the HKDF-derived per-tenant **encryption**
    /// key, in attempt order (current key first).
    ///
    /// The fingerprint is computed over the derived per-tenant encryption key,
    /// not the master key — this matches the per-entry key fingerprint that
    /// cachekit-py stores as frame metadata, so fingerprint-based keyring
    /// selection compares like with like.
    pub fn encryption_fingerprints(
        &self,
        tenant_id: &str,
    ) -> Result<Vec<[u8; 16]>, EncryptionError> {
        self.entries()
            .map(|master| {
                let mut key = derive_encryption_key(master, tenant_id)?;
                let fingerprint = key_fingerprint(&key);
                key.zeroize();
                Ok(fingerprint)
            })
            .collect()
    }

    /// Decrypt with a specific keyring entry (0 = current key).
    ///
    /// For fingerprint-based selection: match the entry via
    /// [`encryption_fingerprints`](Self::encryption_fingerprints), then decrypt
    /// with exactly that entry. A fingerprint match is binding — if the matched
    /// key fails AES-GCM authentication the failure is terminal; do not fall
    /// back to other entries.
    ///
    /// # Errors
    ///
    /// - [`EncryptionError::KeyringIndexOutOfRange`] for an out-of-range index
    ///   — a caller bug, deliberately distinct from any crypto failure.
    /// - [`EncryptionError::KeyDerivation`] if per-tenant key derivation fails
    ///   (e.g. an invalid `tenant_id`) — a configuration error, not a miss.
    /// - [`EncryptionError::AuthenticationFailed`] when this entry's key does
    ///   not authenticate the ciphertext.
    /// - [`EncryptionError::InvalidCiphertext`] for malformed ciphertext.
    pub fn decrypt_at(
        &self,
        index: usize,
        encryptor: &ZeroKnowledgeEncryptor,
        ciphertext: &[u8],
        tenant_id: &str,
        aad: &[u8],
    ) -> Result<Vec<u8>, EncryptionError> {
        let master = self
            .entries()
            .nth(index)
            .ok_or(EncryptionError::KeyringIndexOutOfRange {
                index,
                count: self.entry_count(),
            })?;
        let mut key = derive_encryption_key(master, tenant_id)?;
        let result = encryptor.decrypt_aes_gcm(ciphertext, &key, aad);
        key.zeroize();
        result
    }

    /// Decrypt by sequential keyring attempts: current key first, then each
    /// decrypt-only key in order, rebuilding nothing between attempts — every
    /// attempt uses the identical `aad`.
    ///
    /// Only an AES-GCM authentication failure (the wrong-key signal) advances
    /// to the next key. Structural errors (e.g. ciphertext too short) are
    /// terminal immediately: they would fail identically under every key.
    ///
    /// # Errors
    ///
    /// - [`EncryptionError::AuthenticationFailed`] when no keyring key decrypts
    ///   the ciphertext — the caller's existing fail-open / fail-closed policy
    ///   applies, no new failure mode.
    /// - Terminal (never retried across keys): structural ciphertext errors
    ///   ([`EncryptionError::InvalidCiphertext`]) and configuration errors
    ///   ([`EncryptionError::KeyDerivation`], e.g. an invalid `tenant_id`).
    pub fn decrypt(
        &self,
        encryptor: &ZeroKnowledgeEncryptor,
        ciphertext: &[u8],
        tenant_id: &str,
        aad: &[u8],
    ) -> Result<Vec<u8>, EncryptionError> {
        for index in 0..self.entry_count() {
            match self.decrypt_at(index, encryptor, ciphertext, tenant_id, aad) {
                Err(EncryptionError::AuthenticationFailed) => continue,
                other => return other,
            }
        }
        Err(EncryptionError::AuthenticationFailed)
    }
}

/// Derive the per-tenant encryption key for one keyring entry.
///
/// Identical to the `encryption_key` produced by
/// [`derive_tenant_keys`](super::key_derivation::derive_tenant_keys) — same
/// HKDF construction, same salt/domain — so keyring-derived keys and
/// fingerprints agree byte-for-byte with single-key operation.
fn derive_encryption_key(master: &[u8], tenant_id: &str) -> Result<[u8; 32], EncryptionError> {
    // Surfaces as EncryptionError::KeyDerivation — a configuration error kept
    // deliberately distinct from AuthenticationFailed/DecryptionFailed so a bad
    // tenant_id cannot masquerade as a cache miss under fail-open policies.
    Ok(derive_domain_key(
        master,
        KeyDomain::Encryption.as_str(),
        tenant_id.as_bytes(),
    )?)
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
    use super::super::key_derivation::derive_tenant_keys;
    use super::*;

    const K1: [u8; 32] = [0x11; 32];
    const K2: [u8; 32] = [0x22; 32];
    const TENANT: &str = "tenant-123";
    const AAD: &[u8] = b"test_aad";

    fn encrypt_under(master: &[u8], plaintext: &[u8]) -> Vec<u8> {
        let encryptor = ZeroKnowledgeEncryptor::new().unwrap();
        let key = derive_encryption_key(master, TENANT).unwrap();
        encryptor.encrypt_aes_gcm(plaintext, &key, AAD).unwrap()
    }

    #[test]
    fn test_previous_key_entry_decrypts_after_rotation() {
        // AC: value encrypted under k1 decrypts with keyring [current=k2, prev=[k1]] ...
        let ciphertext = encrypt_under(&K1, b"secret");
        let encryptor = ZeroKnowledgeEncryptor::new().unwrap();

        let keyring = Keyring::new(&K2, &[&K1]).unwrap();
        let plaintext = keyring
            .decrypt(&encryptor, &ciphertext, TENANT, AAD)
            .unwrap();
        assert_eq!(plaintext, b"secret");

        // ... and the same value FAILS with keyring [current=k2, prev=[]] (hard cut-over)
        let cut_over = Keyring::new(&K2, &[]).unwrap();
        let result = cut_over.decrypt(&encryptor, &ciphertext, TENANT, AAD);
        assert!(matches!(result, Err(EncryptionError::AuthenticationFailed)));
    }

    #[test]
    fn test_current_key_decrypts_first() {
        let ciphertext = encrypt_under(&K2, b"fresh write");
        let encryptor = ZeroKnowledgeEncryptor::new().unwrap();

        let keyring = Keyring::new(&K2, &[&K1]).unwrap();
        // Entry 0 is the current key — decrypt_at(0) must succeed directly.
        let plaintext = keyring
            .decrypt_at(0, &encryptor, &ciphertext, TENANT, AAD)
            .unwrap();
        assert_eq!(plaintext, b"fresh write");
    }

    #[test]
    fn test_cap_rejected_never_truncated() {
        // AC: more than MAX_DECRYPT_ONLY_KEYS decrypt-only keys is an error.
        let a = [0x01u8; 32];
        let b = [0x02u8; 32];
        let c = [0x03u8; 32];
        let d = [0x04u8; 32];

        // At the cap: fine.
        assert!(Keyring::new(&K2, &[&a, &b, &c]).is_ok());

        // One over the cap: rejected with the offending count, never truncated.
        let result = Keyring::new(&K2, &[&a, &b, &c, &d]);
        assert!(matches!(
            result,
            Err(EncryptionError::KeyringCapExceeded(4))
        ));
    }

    #[test]
    fn test_current_key_in_decrypt_only_list_rejected() {
        // AC: detectable subset of the forward-only invariant.
        let result = Keyring::new(&K2, &[&K1, &K2]);
        assert!(matches!(
            result,
            Err(EncryptionError::CurrentKeyInDecryptOnlyList)
        ));
    }

    #[test]
    fn test_short_master_key_rejected() {
        let short = [0x01u8; 15];
        assert!(matches!(
            Keyring::new(&short, &[]),
            Err(EncryptionError::InvalidMasterKeyLength(15))
        ));
        assert!(matches!(
            Keyring::new(&K2, &[&short[..]]),
            Err(EncryptionError::InvalidMasterKeyLength(15))
        ));
    }

    #[test]
    fn test_fingerprints_are_derived_key_fingerprints() {
        // AC: per-entry fingerprint == fingerprint of that entry's derived
        // tenant_keys.encryption_key (NOT the master key), in attempt order.
        let keyring = Keyring::new(&K2, &[&K1]).unwrap();
        let fingerprints = keyring.encryption_fingerprints(TENANT).unwrap();

        let k2_tenant = derive_tenant_keys(&K2, TENANT).unwrap();
        let k1_tenant = derive_tenant_keys(&K1, TENANT).unwrap();

        assert_eq!(fingerprints.len(), 2);
        assert_eq!(fingerprints[0], k2_tenant.encryption_fingerprint());
        assert_eq!(fingerprints[1], k1_tenant.encryption_fingerprint());

        // And explicitly NOT the master-key fingerprints.
        assert_ne!(fingerprints[0], key_fingerprint(&K2));
        assert_ne!(fingerprints[1], key_fingerprint(&K1));
    }

    #[test]
    fn test_identical_aad_required_across_all_attempts() {
        // Spec: sequential attempts rebuild the identical AAD; a different AAD
        // must fail even though the encrypting key is present in the keyring.
        let ciphertext = encrypt_under(&K1, b"secret");
        let encryptor = ZeroKnowledgeEncryptor::new().unwrap();

        let keyring = Keyring::new(&K2, &[&K1]).unwrap();
        let result = keyring.decrypt(&encryptor, &ciphertext, TENANT, b"different_aad");
        assert!(matches!(result, Err(EncryptionError::AuthenticationFailed)));
    }

    #[test]
    fn test_structural_error_is_terminal() {
        // A too-short ciphertext is not a wrong-key signal — it must surface
        // as InvalidCiphertext, not be retried into AuthenticationFailed.
        let encryptor = ZeroKnowledgeEncryptor::new().unwrap();
        let keyring = Keyring::new(&K2, &[&K1]).unwrap();

        let result = keyring.decrypt(&encryptor, b"too short", TENANT, AAD);
        assert!(matches!(result, Err(EncryptionError::InvalidCiphertext(_))));
    }

    #[test]
    fn test_decrypt_at_out_of_range() {
        let encryptor = ZeroKnowledgeEncryptor::new().unwrap();
        let keyring = Keyring::new(&K2, &[]).unwrap();
        let ciphertext = encrypt_under(&K2, b"x");

        let result = keyring.decrypt_at(1, &encryptor, &ciphertext, TENANT, AAD);
        assert!(matches!(
            result,
            Err(EncryptionError::KeyringIndexOutOfRange { index: 1, count: 1 })
        ));
    }

    #[test]
    fn test_bad_tenant_id_is_config_error_not_miss() {
        // A derivation failure (empty tenant_id) must surface as KeyDerivation,
        // never as AuthenticationFailed — a bad config cannot masquerade as a
        // cache miss under a fail-open SDK policy.
        let ciphertext = encrypt_under(&K2, b"x");
        let encryptor = ZeroKnowledgeEncryptor::new().unwrap();
        let keyring = Keyring::new(&K2, &[&K1]).unwrap();

        let result = keyring.decrypt(&encryptor, &ciphertext, "", AAD);
        assert!(matches!(result, Err(EncryptionError::KeyDerivation(_))));
    }

    #[test]
    fn test_all_key_material_zeroizes() {
        // AC: keyring key material zeroizes, decrypt-only entries included.
        // ZeroizeOnDrop runs this same Zeroize impl on drop; verifying the
        // explicit zeroize() proves every field is covered (reading freed
        // memory after an actual drop would be UB).
        let mut keyring = Keyring::new(&K2, &[&K1]).unwrap();
        keyring.zeroize();

        assert!(keyring.current.iter().all(|&b| b == 0) || keyring.current.is_empty());
        assert!(keyring
            .decrypt_only
            .iter()
            .all(|key| key.iter().all(|&b| b == 0) || key.is_empty()));

        // Compile-time proof the drop guarantee exists at all.
        fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
        assert_zeroize_on_drop::<Keyring>();
    }
}