Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
185 changes: 178 additions & 7 deletions src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Sha256>::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<u8> {
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<Vec<u8>> {
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]
Expand Down
Loading