From 4df98d8d04e257ede15ce6c4daa0143066a3abef Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 24 Oct 2026 01:14:22 +1100 Subject: [PATCH 1/4] test(store): read what ck writes with an independent frozen-format reader an_older_entry_still_decrypts checks that ck reads old entries. Nothing checked the other direction: a writer change that the same release can still read (a prepended header byte, say) passed every test while an older ck sharing the backend would miss on every entry. The new test stores a value through Store::set and opens the exact bytes handed to the backend with HKDF-SHA256 and AES-256-GCM from RustCrypto, rebuilding the key, the v0x03 AAD and the nonce/ciphertext/tag split from the protocol spec. The reader is pinned to the protocol's basic_bytes vector and to the cachekit-rs 0.9.0 ciphertext, so it reads the frozen format rather than echoing today's writer. aes-gcm, hkdf and sha2 become dev-dependencies at the versions already locked through cachekit-core: no package or version changes in Cargo.lock. Also corrects the old-entry test's doc comment: its ciphertext comes from the EncryptionLayer call ck makes, and its plaintext is not an Envelope. --- Cargo.lock | 3 + Cargo.toml | 3 + src/store.rs | 161 ++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69ae78b..7bbc3ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,14 +114,17 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" name = "cachekit-cli" version = "0.2.1" dependencies = [ + "aes-gcm", "async-trait", "blake2", "cachekit-core", "cachekit-rs", "getrandom 0.2.17", "hex", + "hkdf", "libc", "rustix", + "sha2", "signal-hook", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index f0f551e..7a67ddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,9 @@ signal-hook = "0.4" tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } [dev-dependencies] +aes-gcm = "0.10" async-trait = "0.1" +hkdf = "0.12" rustix = { version = "1", features = ["pty"] } +sha2 = "0.10" tempfile = "3" diff --git a/src/store.rs b/src/store.rs index 6e30529..506de16 100644 --- a/src/store.rs +++ b/src/store.rs @@ -280,18 +280,163 @@ impl Drop for Runtime { mod tests { use super::*; - /// An entry an earlier ck wrote still decrypts. The ciphertext came from - /// cachekit-rs 0.9.0 with cachekit-core 0.6.0, master key `[7; 32]`, under - /// the key from `keys::tests::value_key_known_answer`. + /// The key from `keys::tests::value_key_known_answer`. + const OLD_KEY: &str = + "ns:ck-run:v1:041385329ef05862f32fad778ee6c2a69581637fd18926b3b0e8819aee6662ec"; + /// `EncryptionLayer::encrypt(b"ck known answer", OLD_KEY)` with master key + /// `[7; 32]`, on cachekit-rs 0.9.0 and cachekit-core 0.6.0. + const OLD_CIPHERTEXT: &str = + "c90083142ccd441f0000000081198d2ce5728477cfab8528cb949c6393c0eeecc2dce5218b717e7fb3ea36"; + + /// A ciphertext from the same `EncryptionLayer` call ck makes, on + /// cachekit-rs 0.9.0 and cachekit-core 0.6.0, still decrypts. Its + /// plaintext is a fixed string, not an `Envelope`. #[test] fn an_older_entry_still_decrypts() { - let key = "ns:ck-run:v1:041385329ef05862f32fad778ee6c2a69581637fd18926b3b0e8819aee6662ec"; - let ciphertext = hex::decode( - "c90083142ccd441f0000000081198d2ce5728477cfab8528cb949c6393c0eeecc2dce5218b717e7fb3ea36", + let layer = EncryptionLayer::new(&[7u8; 32], TENANT).unwrap(); + let ciphertext = hex::decode(OLD_CIPHERTEXT).unwrap(); + assert_eq!( + layer.decrypt(&ciphertext, OLD_KEY).unwrap(), + b"ck known answer" + ); + } + + /// The frozen encryption format, rebuilt from the protocol spec on + /// RustCrypto alone. Neither cachekit-rs nor cachekit-core derives the key + /// or opens the ciphertext here, so a change to their writer cannot carry + /// this reader along with it. + mod frozen { + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::{Aes256Gcm, Nonce}; + use hkdf::Hkdf; + use sha2::{Digest, Sha256}; + + /// HKDF-SHA256 for the `encryption` domain. The salt is + /// `"cachekit_v1_" ‖ u8 len ‖ domain ‖ u16be len ‖ tenant`, and the + /// info is the domain. + pub fn key(master_key: &[u8], tenant: &str) -> [u8; 32] { + let domain = b"encryption"; + let mut salt = b"cachekit_v1_".to_vec(); + salt.push(domain.len() as u8); + salt.extend_from_slice(domain); + salt.extend_from_slice(&(tenant.len() as u16).to_be_bytes()); + salt.extend_from_slice(tenant.as_bytes()); + let mut key = [0; 32]; + Hkdf::::new(Some(&salt), master_key) + .expand(domain, &mut key) + .unwrap(); + key + } + + /// The protocol's key fingerprint: the first 16 bytes of + /// SHA-256(`"key_fingerprint_v1"` ‖ key). + pub fn fingerprint(key: &[u8]) -> Vec { + Sha256::new() + .chain_update(b"key_fingerprint_v1") + .chain_update(key) + .finalize()[..16] + .to_vec() + } + + /// AAD v0x03 for an uncompressed msgpack entry: `0x03`, then tenant, + /// cache key, `"msgpack"` and `"False"`, each after a u32be length. + pub fn aad(tenant: &str, cache_key: &str) -> Vec { + let mut aad = vec![0x03]; + for part in [ + tenant.as_bytes(), + cache_key.as_bytes(), + b"msgpack", + b"False", + ] { + aad.extend_from_slice(&(part.len() as u32).to_be_bytes()); + aad.extend_from_slice(part); + } + aad + } + + /// Opens `nonce(12) ‖ ciphertext ‖ tag(16)`, with nothing before or + /// after it. + pub fn open( + master_key: &[u8], + tenant: &str, + cache_key: &str, + stored: &[u8], + ) -> Option> { + let (nonce, sealed) = stored.split_at_checked(12)?; + let aad = aad(tenant, cache_key); + Aes256Gcm::new_from_slice(&key(master_key, tenant)) + .unwrap() + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: sealed, + aad: &aad, + }, + ) + .ok() + } + } + + /// The independent reader agrees with the protocol's published vectors + /// (`test-vectors/encryption.json` 1.5.0, `basic_bytes`) and with the + /// ciphertext an earlier cachekit-rs wrote, so it reads the frozen format + /// and not just whatever today's writer produces. + #[test] + fn the_frozen_reader_reads_the_protocol_vectors() { + let master_key = [0x61u8; 32]; + let tenant = "cross-sdk-test"; + assert_eq!( + hex::encode(frozen::fingerprint(&frozen::key(&master_key, tenant))), + "96179a9bc881aa7ca83f04b78a66afd3" + ); + assert_eq!( + hex::encode(frozen::aad(tenant, "test:vector:1")), + "030000000e63726f73732d73646b2d746573740000000d746573743a766563746f723a31\ + 000000076d73677061636b0000000546616c7365" + ); + let ciphertext = + hex::decode("f8f69c70000000000000000087d9ec09f2c2347a4b37d5330ccf50e3c31e69642801bef5") + .unwrap(); + assert_eq!( + frozen::open(&master_key, tenant, "test:vector:1", &ciphertext), + Some(vec![1, 2, 3, 4, 5, 6, 7, 8]) + ); + let ciphertext = hex::decode(OLD_CIPHERTEXT).unwrap(); + assert_eq!( + frozen::open(&[7u8; 32], TENANT, OLD_KEY, &ciphertext).as_deref(), + Some(&b"ck known answer"[..]) + ); + } + + /// What ck writes now is readable under the frozen format, so an older ck + /// sharing the backend still gets a hit. This catches a writer change that + /// the same release's reader accepts, which `an_older_entry_still_decrypts` + /// cannot: the bytes checked are the ones ck hands to the backend. + #[test] + fn what_ck_writes_the_frozen_format_reads() { + let master_key = [7u8; 32]; + let dir = tempfile::tempdir().unwrap(); + // The file backend refuses a group- or other-writable directory, and + // the tempdir's mode follows the umask. + std::fs::set_permissions( + dir.path(), + std::os::unix::fs::PermissionsExt::from_mode(0o700), ) .unwrap(); - let layer = EncryptionLayer::new(&[7u8; 32], TENANT).unwrap(); - assert_eq!(layer.decrypt(&ciphertext, key).unwrap(), b"ck known answer"); + let store = Store::open_file(dir.path(), &master_key).unwrap(); + let plaintext = crate::entry::Envelope::encode(1, b"ck known answer"); + store + .set(OLD_KEY, &plaintext, Duration::from_secs(60)) + .unwrap(); + let stored = store + .runtime + .block_on(store.backend.get(OLD_KEY)) + .unwrap() + .unwrap(); + assert_eq!( + frozen::open(&master_key, TENANT, OLD_KEY, &stored), + Some(plaintext) + ); } #[test] From 1995f6d09816d60413dac99948d2a5809df36d6f Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 24 Oct 2026 01:27:47 +1100 Subject: [PATCH 2/4] test(store): check a large write, narrow the claim, sharpen the failure The writer check now also stores the saas capture cap's worth of zeros, so a writer branch gated on size (compression past a threshold, say) cannot pass unseen. Its doc comment claims only what it shows: the bytes put hands to the backend open under the frozen encryption format. A failure now names both lengths and the leading stored bytes. The reader's docs say it covers the subset ck writes. The key-fingerprint check is dropped, since the basic_bytes open already pins the derived key. OLD_KEY becomes KNOWN_ANSWER_KEY, and the old ciphertext's master key is a named constant. --- src/store.rs | 92 +++++++++++++++++++++++++--------------------------- 1 file changed, 45 insertions(+), 47 deletions(-) diff --git a/src/store.rs b/src/store.rs index 506de16..3ebf469 100644 --- a/src/store.rs +++ b/src/store.rs @@ -281,10 +281,12 @@ mod tests { use super::*; /// The key from `keys::tests::value_key_known_answer`. - const OLD_KEY: &str = + const KNOWN_ANSWER_KEY: &str = "ns:ck-run:v1:041385329ef05862f32fad778ee6c2a69581637fd18926b3b0e8819aee6662ec"; - /// `EncryptionLayer::encrypt(b"ck known answer", OLD_KEY)` with master key - /// `[7; 32]`, on cachekit-rs 0.9.0 and cachekit-core 0.6.0. + /// The master key `OLD_CIPHERTEXT` was written under. + const OLD_MASTER_KEY: [u8; 32] = [7; 32]; + /// `EncryptionLayer::encrypt(b"ck known answer", KNOWN_ANSWER_KEY)` under + /// `OLD_MASTER_KEY`, on cachekit-rs 0.9.0 and cachekit-core 0.6.0. const OLD_CIPHERTEXT: &str = "c90083142ccd441f0000000081198d2ce5728477cfab8528cb949c6393c0eeecc2dce5218b717e7fb3ea36"; @@ -293,28 +295,28 @@ mod tests { /// plaintext is a fixed string, not an `Envelope`. #[test] fn an_older_entry_still_decrypts() { - let layer = EncryptionLayer::new(&[7u8; 32], TENANT).unwrap(); + let layer = EncryptionLayer::new(&OLD_MASTER_KEY, TENANT).unwrap(); let ciphertext = hex::decode(OLD_CIPHERTEXT).unwrap(); assert_eq!( - layer.decrypt(&ciphertext, OLD_KEY).unwrap(), + layer.decrypt(&ciphertext, KNOWN_ANSWER_KEY).unwrap(), b"ck known answer" ); } - /// The frozen encryption format, rebuilt from the protocol spec on - /// RustCrypto alone. Neither cachekit-rs nor cachekit-core derives the key - /// or opens the ciphertext here, so a change to their writer cannot carry - /// this reader along with it. + /// The subset of the frozen encryption format that ck writes, rebuilt + /// from the protocol spec on RustCrypto alone. Neither cachekit-rs nor + /// cachekit-core derives the key or opens the ciphertext here, so a change + /// to their writer cannot carry this reader along with it. mod frozen { use aes_gcm::aead::{Aead, KeyInit, Payload}; use aes_gcm::{Aes256Gcm, Nonce}; use hkdf::Hkdf; - use sha2::{Digest, Sha256}; + use sha2::Sha256; /// HKDF-SHA256 for the `encryption` domain. The salt is /// `"cachekit_v1_" ‖ u8 len ‖ domain ‖ u16be len ‖ tenant`, and the /// info is the domain. - pub fn key(master_key: &[u8], tenant: &str) -> [u8; 32] { + fn key(master_key: &[u8], tenant: &str) -> [u8; 32] { let domain = b"encryption"; let mut salt = b"cachekit_v1_".to_vec(); salt.push(domain.len() as u8); @@ -328,18 +330,9 @@ mod tests { key } - /// The protocol's key fingerprint: the first 16 bytes of - /// SHA-256(`"key_fingerprint_v1"` ‖ key). - pub fn fingerprint(key: &[u8]) -> Vec { - Sha256::new() - .chain_update(b"key_fingerprint_v1") - .chain_update(key) - .finalize()[..16] - .to_vec() - } - - /// AAD v0x03 for an uncompressed msgpack entry: `0x03`, then tenant, - /// cache key, `"msgpack"` and `"False"`, each after a u32be length. + /// The four-part v0x03 AAD ck writes, for an uncompressed msgpack + /// entry: `0x03`, then tenant, cache key, `"msgpack"` and `"False"`, + /// each after a u32be length. pub fn aad(tenant: &str, cache_key: &str) -> Vec { let mut aad = vec![0x03]; for part in [ @@ -355,7 +348,7 @@ mod tests { } /// Opens `nonce(12) ‖ ciphertext ‖ tag(16)`, with nothing before or - /// after it. + /// after it, under the AAD ck writes. pub fn open( master_key: &[u8], tenant: &str, @@ -385,10 +378,6 @@ mod tests { fn the_frozen_reader_reads_the_protocol_vectors() { let master_key = [0x61u8; 32]; let tenant = "cross-sdk-test"; - assert_eq!( - hex::encode(frozen::fingerprint(&frozen::key(&master_key, tenant))), - "96179a9bc881aa7ca83f04b78a66afd3" - ); assert_eq!( hex::encode(frozen::aad(tenant, "test:vector:1")), "030000000e63726f73732d73646b2d746573740000000d746573743a766563746f723a31\ @@ -403,18 +392,20 @@ mod tests { ); let ciphertext = hex::decode(OLD_CIPHERTEXT).unwrap(); assert_eq!( - frozen::open(&[7u8; 32], TENANT, OLD_KEY, &ciphertext).as_deref(), + frozen::open(&OLD_MASTER_KEY, TENANT, KNOWN_ANSWER_KEY, &ciphertext).as_deref(), Some(&b"ck known answer"[..]) ); } - /// What ck writes now is readable under the frozen format, so an older ck - /// sharing the backend still gets a hit. This catches a writer change that - /// the same release's reader accepts, which `an_older_entry_still_decrypts` - /// cannot: the bytes checked are the ones ck hands to the backend. + /// The bytes ck's `put` hands to the backend open under the frozen + /// encryption format. This catches a writer change that the same release's + /// reader accepts, which `an_older_entry_still_decrypts` cannot. A small + /// value and a large, compressible one (the saas capture cap's worth of + /// zeros) are both checked, so a writer branch gated on size, such as + /// compression past a threshold, cannot pass unseen. #[test] fn what_ck_writes_the_frozen_format_reads() { - let master_key = [7u8; 32]; + let master_key = [9u8; 32]; let dir = tempfile::tempdir().unwrap(); // The file backend refuses a group- or other-writable directory, and // the tempdir's mode follows the umask. @@ -424,19 +415,26 @@ mod tests { ) .unwrap(); let store = Store::open_file(dir.path(), &master_key).unwrap(); - let plaintext = crate::entry::Envelope::encode(1, b"ck known answer"); - store - .set(OLD_KEY, &plaintext, Duration::from_secs(60)) - .unwrap(); - let stored = store - .runtime - .block_on(store.backend.get(OLD_KEY)) - .unwrap() - .unwrap(); - assert_eq!( - frozen::open(&master_key, TENANT, OLD_KEY, &stored), - Some(plaintext) - ); + for stdout in [b"ck known answer".to_vec(), vec![0; SAAS_MAX_OUTPUT]] { + let plaintext = Envelope::encode(1, &stdout); + store + .set(KNOWN_ANSWER_KEY, &plaintext, Duration::from_secs(60)) + .unwrap(); + let stored = store + .runtime + .block_on(store.backend.get(KNOWN_ANSWER_KEY)) + .unwrap() + .unwrap(); + let opened = frozen::open(&master_key, TENANT, KNOWN_ANSWER_KEY, &stored); + assert!( + opened.as_ref() == Some(&plaintext), + "a {}-byte envelope was stored as {} bytes that the frozen format does not open \ + to it; they start {}", + plaintext.len(), + stored.len(), + hex::encode(&stored[..stored.len().min(32)]) + ); + } } #[test] From aabf5f2abe6f13d4b58f02d1302b942103a4f38a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 24 Oct 2026 01:39:44 +1100 Subject: [PATCH 3/4] test(store): size the large write to the store's own output cap The file store keeps up to OUTPUT_CAP, not the saas cap, so a 1 MiB write let a writer branch gated between the two pass unseen. The large case now writes store.max_output() zeros. --- src/store.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/store.rs b/src/store.rs index 3ebf469..89a835f 100644 --- a/src/store.rs +++ b/src/store.rs @@ -400,9 +400,9 @@ mod tests { /// The bytes ck's `put` hands to the backend open under the frozen /// encryption format. This catches a writer change that the same release's /// reader accepts, which `an_older_entry_still_decrypts` cannot. A small - /// value and a large, compressible one (the saas capture cap's worth of - /// zeros) are both checked, so a writer branch gated on size, such as - /// compression past a threshold, cannot pass unseen. + /// value and a large, compressible one (the most output this store keeps, + /// as zeros) are both checked, so a writer branch gated on any size ck can + /// write, such as compression past a threshold, cannot pass unseen. #[test] fn what_ck_writes_the_frozen_format_reads() { let master_key = [9u8; 32]; @@ -415,7 +415,7 @@ mod tests { ) .unwrap(); let store = Store::open_file(dir.path(), &master_key).unwrap(); - for stdout in [b"ck known answer".to_vec(), vec![0; SAAS_MAX_OUTPUT]] { + for stdout in [b"ck known answer".to_vec(), vec![0; store.max_output()]] { let plaintext = Envelope::encode(1, &stdout); store .set(KNOWN_ANSWER_KEY, &plaintext, Duration::from_secs(60)) From a332246c267396f715ee1314dd7baf98a085eb7d Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 24 Oct 2026 01:57:09 +1100 Subject: [PATCH 4/4] test(store): write under a mixed-byte key across a ladder of sizes Every key the format checks used repeated one byte, so it read the same reversed and could not show a byte-order mistake in the key's path into HKDF, in the writer or in the reference reader. The writer check now uses the protocol's master_key_every_hex_digit key (32 distinct bytes), and the reader is pinned to that vector's sealed entry. Two sizes could not catch a writer branch gated on a range between them. The writer check now also stores zeros at every power of 4 from 1 byte to 16 MiB, and its doc says what that covers. --- src/store.rs | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/store.rs b/src/store.rs index 89a835f..1b7459b 100644 --- a/src/store.rs +++ b/src/store.rs @@ -370,10 +370,17 @@ mod tests { } } + /// 32 distinct bytes: the protocol's `master_key_every_hex_digit` key. A + /// key that repeats one byte reads the same reversed, so it cannot show a + /// byte-order mistake in the key's path into HKDF. + const MIXED_MASTER_KEY: &str = + "007f80ffa55ac33c1ee12dd24bb469968778f00f01102332455467768998abba"; + /// The independent reader agrees with the protocol's published vectors - /// (`test-vectors/encryption.json` 1.5.0, `basic_bytes`) and with the - /// ciphertext an earlier cachekit-rs wrote, so it reads the frozen format - /// and not just whatever today's writer produces. + /// (`test-vectors/encryption.json` 1.5.0, `basic_bytes` and + /// `master_key_every_hex_digit`) and with the ciphertext an earlier + /// cachekit-rs wrote, so it reads the frozen format and not just whatever + /// today's writer produces. #[test] fn the_frozen_reader_reads_the_protocol_vectors() { let master_key = [0x61u8; 32]; @@ -390,6 +397,21 @@ mod tests { frozen::open(&master_key, tenant, "test:vector:1", &ciphertext), Some(vec![1, 2, 3, 4, 5, 6, 7, 8]) ); + let cache_key = + "users:get_all:8064bf5f945dba521b017bb3f32b31a293a6e16944838d171ea9e77fab085e8e"; + let ciphertext = hex::decode( + "3a3b3c3d3e3f404142434445d282555c393cc14a53d488e005e6db441d92e9fe089e97b3ecc99a86014810c745", + ) + .unwrap(); + assert_eq!( + frozen::open( + &hex::decode(MIXED_MASTER_KEY).unwrap(), + "default", + cache_key, + &ciphertext + ), + Some(hex::decode("9501a374776fcb400c000000000000c0c3").unwrap()) + ); let ciphertext = hex::decode(OLD_CIPHERTEXT).unwrap(); assert_eq!( frozen::open(&OLD_MASTER_KEY, TENANT, KNOWN_ANSWER_KEY, &ciphertext).as_deref(), @@ -399,13 +421,15 @@ mod tests { /// The bytes ck's `put` hands to the backend open under the frozen /// encryption format. This catches a writer change that the same release's - /// reader accepts, which `an_older_entry_still_decrypts` cannot. A small - /// value and a large, compressible one (the most output this store keeps, - /// as zeros) are both checked, so a writer branch gated on any size ck can - /// write, such as compression past a threshold, cannot pass unseen. + /// reader accepts, which `an_older_entry_still_decrypts` cannot. The + /// master key has 32 distinct bytes. Besides a short text value, it + /// writes compressible zeros at every power of 4 from 1 byte to 16 MiB, + /// and at the most output this store keeps. So a writer branch gated on + /// size, such as compression past a threshold, passes unseen only if its + /// whole range falls between two neighbouring sizes. #[test] fn what_ck_writes_the_frozen_format_reads() { - let master_key = [9u8; 32]; + let master_key = hex::decode(MIXED_MASTER_KEY).unwrap(); let dir = tempfile::tempdir().unwrap(); // The file backend refuses a group- or other-writable directory, and // the tempdir's mode follows the umask. @@ -415,7 +439,11 @@ mod tests { ) .unwrap(); let store = Store::open_file(dir.path(), &master_key).unwrap(); - for stdout in [b"ck known answer".to_vec(), vec![0; store.max_output()]] { + let sizes = (0..=12) + .map(|k| 1 << (2 * k)) + .chain([0, store.max_output()]); + let values = std::iter::once(b"ck known answer".to_vec()).chain(sizes.map(|n| vec![0; n])); + for stdout in values { let plaintext = Envelope::encode(1, &stdout); store .set(KNOWN_ANSWER_KEY, &plaintext, Duration::from_secs(60))