diff --git a/Cargo.lock b/Cargo.lock index cdf672b..2296b55 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..1b7459b 100644 --- a/src/store.rs +++ b/src/store.rs @@ -280,18 +280,189 @@ 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 KNOWN_ANSWER_KEY: &str = + "ns:ck-run:v1:041385329ef05862f32fad778ee6c2a69581637fd18926b3b0e8819aee6662ec"; + /// 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"; + + /// 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 layer = EncryptionLayer::new(&OLD_MASTER_KEY, TENANT).unwrap(); + let ciphertext = hex::decode(OLD_CIPHERTEXT).unwrap(); + assert_eq!( + layer.decrypt(&ciphertext, KNOWN_ANSWER_KEY).unwrap(), + b"ck known answer" + ); + } + + /// 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::Sha256; + + /// HKDF-SHA256 for the `encryption` domain. The salt is + /// `"cachekit_v1_" ‖ u8 len ‖ domain ‖ u16be len ‖ tenant`, and the + /// info is the domain. + 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 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 [ + 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, under the AAD ck writes. + 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() + } + } + + /// 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 + /// `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]; + let tenant = "cross-sdk-test"; + 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 cache_key = + "users:get_all:8064bf5f945dba521b017bb3f32b31a293a6e16944838d171ea9e77fab085e8e"; let ciphertext = hex::decode( - "c90083142ccd441f0000000081198d2ce5728477cfab8528cb949c6393c0eeecc2dce5218b717e7fb3ea36", + "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(), + Some(&b"ck known answer"[..]) + ); + } + + /// 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. 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 = 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. + 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 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)) + .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]