cachekit-core
LZ4 compression, xxHash3 integrity, AES-256-GCM encryption — for arbitrary byte payloads.
Features · Quick Start · FFI · Security · Architecture
Overview
cachekit-core transforms byte payloads: compress them, verify their integrity, encrypt them. Bytes in, bytes out.
| Component | What it does |
|---|---|
| ByteStorage | &[u8] → LZ4 compress → xxHash3 checksum → Vec<u8> envelope |
| Encryption | &[u8] → AES-256-GCM encrypt → Vec<u8> ciphertext |
| Key Derivation | Master key → HKDF-SHA256 → derived key per tenant/domain |
[!TIP] For decorator-based caching in Python, see
cachekit-py.
Features
| Feature | Description | Default |
|---|---|---|
compression |
LZ4 compression via lz4_flex |
✅ |
checksum |
xxhash-rust integrity verification |
✅ |
encryption |
AES-256-GCM (ring on native, aes-gcm on wasm32) + HKDF-SHA256 (hkdf) |
❌ |
ffi |
C header generation | ❌ |
# Cargo.toml - defaults only
[]
= "0.1"
# With encryption
[]
= { = "0.1", = ["encryption"] }
# For C FFI development
[]
= { = "0.1", = ["ffi", "encryption"] }
Quick Start
Basic Storage (Compress + Checksum)
use ByteStorage;
// Create storage with default format
let storage = new;
// Store data (compresses + checksums automatically)
let data = b"Hello, cachekit!";
let envelope = storage.store?;
// Retrieve data (decompresses + verifies checksum)
let = storage.retrieve?;
assert_eq!;
With Encryption (Zero-Knowledge)
use ;
use Zeroizing; // add the zeroize crate to your Cargo.toml
// Derive tenant-isolated key from master secret
// From your secret manager or CACHEKIT_MASTER_KEY, hex-decoded to 32 raw bytes.
// Never hard-code it, and never pass the hex string's bytes.
// Zeroizing wipes each key from memory when it is dropped.
let master_key = new;
let tenant_key = new;
// Encrypt sensitive data
let encryptor = new?;
let plaintext = b"sensitive user data";
let aad = b"tenant-12345"; // Additional authenticated data
let ciphertext = encryptor.encrypt_aes_gcm?;
// Decrypt (fails if AAD doesn't match)
let decrypted = encryptor.decrypt_aes_gcm?;
assert_eq!;
[!IMPORTANT] Key Management: Never hardcode keys. Use environment variables or a secrets manager. The
CACHEKIT_MASTER_KEYenvironment variable is the recommended approach.
use ;
C FFI
Build with FFI feature to generate include/cachekit.h:
This produces:
target/release/libcachekit_core.{so,dylib,dll}— Shared libraryinclude/cachekit.h— C header file
int
Compile:
Security
┌─────────────────────────────────────────────────────────────────┐
│ Security Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Master Key ──┬──► HKDF-SHA256 ──► Tenant Key A │
│ │ │
│ ├──► HKDF-SHA256 ──► Tenant Key B │
│ │ │
│ └──► HKDF-SHA256 ──► Tenant Key N │
│ │
│ Each tenant key provides: │
│ • Cryptographic isolation (compromise one ≠ compromise all) │
│ • Domain separation (cache vs auth vs sessions) │
│ • Master-key rotation via decrypt-only keyring (grace window) │
│ │
└─────────────────────────────────────────────────────────────────┘
| Property | Implementation |
|---|---|
| Encryption | AES-256-GCM (AEAD) via ring on native, aes-gcm on wasm32 |
| Key Derivation | HKDF-SHA256 (RFC 5869) via hkdf |
| Integrity | xxhash-rust (xxHash3-64) |
| Nonce Safety | Counter-based + random IV (no reuse) |
| Memory Safety | zeroize on drop for all key material |
| Timing Safety | Constant-time AEAD tag verification: ring on native, aes-gcm on wasm32 |
[!WARNING] Nonce Counter: Each
ZeroKnowledgeEncryptorinstance supports 2³² encryptions before requiring rotation. The FFI layer returnsCACHEKIT_ROTATION_NEEDEDat 2³¹ operations as an early warning.
All decompression operations enforce:
| Limit | Value | Purpose |
|---|---|---|
| Max uncompressed size | 512 MB | Memory exhaustion prevention |
| Max compressed size | 512 MB | Input validation |
| Max compression ratio | 1000x | Decompression bomb detection |
Malicious payloads claiming original_size: 500GB with 100 bytes of data are rejected before decompression.
retrieve() and validate() run a header-only structural pre-scan over the
envelope bytes before MessagePack decoding: nesting deeper than 100 levels,
headers declaring more elements or bytes than the input can back, the reserved
marker 0xc1 and truncated input are all rejected before decoding, without
allocating in proportion to any declared length. A rejection is
ByteStorageError::DeserializationFailed with the message prefix
decode pre-scan: . The same walk is public as
check_msgpack_structure(bytes, max_depth) for callers that decode untrusted
MessagePack themselves; it returns a MsgpackStructureError whose Display is
the bare reason, with no prefix. msgpack_document_len(bytes, max_depth) runs
the same walk and returns the document's length, so a caller that must reject
trailing bytes compares it with bytes.len() instead of walking twice. See
SECURITY.md.
Architecture
cachekit-core/
├── src/
│ ├── lib.rs # Public API exports
│ ├── byte_storage.rs # LZ4 + xxHash3 storage envelope
│ ├── msgpack_bounds.rs # Structural pre-scan (public check_msgpack_structure, msgpack_document_len), run before the envelope decode
│ ├── checksum.rs # Standalone xxHash3 checksum/verify primitive (feature = "checksum")
│ │
│ ├── encryption/ # (feature = "encryption")
│ │ ├── mod.rs # Module exports
│ │ ├── core.rs # AES-256-GCM implementation
│ │ ├── key_derivation.rs # HKDF-SHA256 + tenant isolation
│ │ └── keyring.rs # Multi-key decrypt keyring (master-key rotation)
│ │
│ └── ffi/ # (feature = "ffi")
│ ├── mod.rs # FFI exports
│ ├── error.rs # C-compatible error codes
│ ├── handles.rs # Opaque handle management
│ ├── byte_storage.rs # ByteStorage FFI bindings
│ └── encryption.rs # Encryption FFI bindings
│
├── include/
│ └── cachekit.h # Generated C header
│
├── benches/
│ ├── hot_path.rs # Criterion wall-clock suite (make bench)
│ └── perf_ir.rs # Per-op instruction counts and their gate (make perf-ir)
│
├── fuzz/ # Fuzzing targets (16 targets)
│ └── fuzz_targets/
│
└── tests/ # Integration & property tests
Performance
Single-threaded time per call of cachekit-core's own operations, and the payload bytes each one processes per second, measured with examples/bench_throughput.rs on GitHub-hosted runners with Rust 1.99.1.
x86-64 Linux: ubuntu-latest runner, AMD EPYC 7763 (4 vCPUs), AES-NI
| Operation | 1 KiB | 64 KiB |
|---|---|---|
ByteStorage::store |
1.46 µs · 0.70 GB/s | 63.31 µs · 1.04 GB/s |
ByteStorage::retrieve |
555 ns · 1.84 GB/s | 25.26 µs · 2.59 GB/s |
encrypt_aes_gcm |
444 ns · 2.31 GB/s | 10.93 µs · 5.99 GB/s |
decrypt_aes_gcm |
455 ns · 2.25 GB/s | 10.52 µs · 6.23 GB/s |
arm64 macOS: macos-latest runner, Apple M1 (virtual, 3 vCPUs), ARM Crypto Extensions
| Operation | 1 KiB | 64 KiB |
|---|---|---|
ByteStorage::store |
1.81 µs · 0.57 GB/s | 98.92 µs · 0.66 GB/s |
ByteStorage::retrieve |
602 ns · 1.70 GB/s | 21.46 µs · 3.05 GB/s |
encrypt_aes_gcm |
526 ns · 1.95 GB/s | 13.79 µs · 4.75 GB/s |
decrypt_aes_gcm |
568 ns · 1.80 GB/s | 13.26 µs · 4.94 GB/s |
storecompresses with LZ4 (an input under 64 bytes is written as one literals-only LZ4 block, without running the compressor), checksums with xxHash3-64 and wraps both in the MessagePack envelope.retrievechecks the envelope, decompresses and verifies the checksum.encrypt_aes_gcmanddecrypt_aes_gcmareZeroKnowledgeEncryptor's AES-256-GCM calls with a 34-byte AAD.- The payload is MessagePack-encoded records (
benches/common/mod.rs), the corpushot_pathandperf_iralso use. LZ4 compresses it to 0.57 of its size at 1 KiB and to 0.38 at 64 KiB. - Each figure is the median of 21 timed batches, from one run on the CPU named. Two runs of this code on the EPYC 7763 agreed within 2% on every cell but one (
decrypt_aes_gcmat 64 KiB, 23%); three arm64 runs varied by up to 30% on a cell. - Hosted runners are shared virtual machines, and x86-64 ones also come with other CPU models (AMD EPYC 9V74 and 9V45, Intel Xeon 6973P-C). On those, the same calls took different times, for example 0.6 times as long (
decrypt_aes_gcmat 64 KiB on the 9V45) and 1.4 times as long (storeat 64 KiB on the Xeon). Other machines give other figures.
To print the table for your own machine, run cargo run --release --example bench_throughput --features encryption, or run the Bench workflow from the Actions tab.
[!NOTE] Earlier versions of this table timed the raw LZ4, xxHash3 and AES primitives, not cachekit-core's calls, on a 256-byte repeating pattern that LZ4 compresses almost perfectly. At 64 KiB they read about 15 GB/s for LZ4 compression and 37 GB/s for decompression; on real payloads,
storeandretrievein the tables above move 0.6 to 3.1 GB/s.
[!TIP] Hardware acceleration is auto-detected. ARM64 uses ARM Crypto Extensions; x86-64 uses AES-NI.
The Criterion suite in benches/hot_path.rs needs the encryption feature (make bench, or cargo bench --features encryption; a plain cargo bench skips it). It runs the ByteStorage roundtrip on three corpora side by side: byte_storage/roundtrip (synthetic ramp, kept for history), byte_storage/roundtrip_msgpack (realistic msgpack records, about 0.38 LZ4 ratio at 64 KB) and byte_storage/roundtrip_incompressible. The bench profile keeps symbols, so callgrind and perf attribute cost to functions.
Instruction counts
make perf-ir counts the instructions each hot path executes per call and fails when a case costs 1% or more above its committed budget. It warns from 0.2%. Wall clock on a shared machine moves by far more than 1% between identical runs, and Criterion picks its iteration count from wall time; instruction counts at a fixed iteration count are exact. It needs valgrind (apt install valgrind).
| Case | What one call does |
|---|---|
store/<size>, retrieve/<size> |
ByteStorage::store / retrieve on the realistic msgpack payload hot_path uses |
prescan/<size> |
check_msgpack_structure on the envelope, the pre-scan retrieve runs first |
encrypt/<size>, decrypt/<size> |
ZeroKnowledgeEncryptor AES-256-GCM |
keyring_decrypt/<size> |
Keyring::decrypt_indexed, which re-derives the tenant key (HKDF) on every call |
tenant_keyring_decrypt/<size> |
TenantKeyring::decrypt_indexed, key derived once at for_tenant |
hkdf |
derive_domain_key (takes no payload, so it has no size) |
Sizes are 64 B, 1 KiB and 64 KiB. One case at a time: make perf-ir ARGS="--case store/1024".
Method (benches/perf_ir.rs): each case runs in its own process under valgrind --tool=cachegrind --cache-sim=no, once with 1,000 calls and once with none, after the same setup and one warm-up call. Ir/op is (Ir[1000] - Ir[0]) / 1000, so process start, setup and exit cancel. The measured process gets an empty environment, so neither the shell nor a VALGRIND_OPTS changes what is counted.
Reproducibility: two runs of the same build agree exactly on every case. Between builds, the heap moves with the size of the binary, and alignment-dependent code in AES-GCM and memcpy follows it: encrypt/1024 moves by up to 0.19%, every other case by at most 0.02%. The counts also depend on the compiler, the locked dependencies, the CPU features the binary is compiled for and the ones valgrind passes through. Budgets are keyed by architecture, OS and any compiled-in CPU features (a -C target-cpu=x86-64-v3 build gets its own set), and each set records the rustc and valgrind versions it was taken with; the gate prints a note when yours differ. Other RUSTFLAGS and profile overrides are not detected, so measure the default build.
Budgets live in benches/perf_ir_baselines.json. After a change that makes a path cheaper, make perf-ir-update ratchets its budget down. It never raises one: an increase you mean takes make perf-ir-update ARGS=--allow-increase. Ratcheting works only on the rustc and valgrind the budgets were recorded with; on a new toolchain, re-record every case with make perf-ir-update ARGS=--allow-increase (no --case). Instruction counts ignore cache misses and branch mispredictions, so a claimed wall-clock win still needs a wall-clock A/B.
In CI the perf-ir job runs make perf-ir-vs-base on a GitHub-hosted ubuntu-latest runner for every pull request. Committed budgets hold only on the machine they were recorded on, so the job does not use them: it measures the library at the pull request's merge-base with main in a temporary worktree, then gates the pull request against those figures, on the same runner, toolchain and valgrind. A case fails at +1% Ir/op, as above. Both sides run the pull request's benches/, so a change to a case's fixture or method applies to both and cannot pass as a library improvement; the base keeps its own Cargo.toml and Cargo.lock, so dependency changes are measured. If those benches cannot build against the base (they call API the base lacks, or the change edits bench dev-dependencies or [[bench]] targets), the job fails as not comparable and the change needs a human call. Run the same comparison locally with make perf-ir-vs-base (default base origin/main; PERF_IR_BASE=<ref> for another). The gate's --baselines <path> option, which the script uses, reads and writes a budget file other than the committed one.
Testing
# Run all tests
# Run with specific feature
# Property-based tests
# Fuzzing (requires cargo-fuzz)
&&
See fuzz/README.md for comprehensive fuzzing documentation.
Protocol wire-format vectors
tests/wire_format_vectors.rs byte-verifies this crate against the canonical
ByteStorage envelope vectors from
cachekit-io/protocol
(test-vectors/wire-format.json). Since protocol 1.1 the fixture pins two
compressed_data encodings — legacy array-of-integers vectors and their
*_bin twins (msgpack bin, canonical for 1.1+ writers): every vector in
both sets must decode to the exact payload bytes, while re-encode
byte-identity is asserted against the set matching this crate's current
writer encoding — msgpack bin, since the protocol 1.1 serde_bytes
writer flip (checksum deliberately stays array-of-ints per the
protocol's normative scope exclusion). Legacy envelopes remain readable
forever.
tests/dual_decode.rs proves both reader shapes accept both encodings,
including bin16/bin32 width headers. The fixture is vendored at
tests/vectors/wire-format.json and integrity-pinned by sha256 — to update
it, re-copy from the protocol repo and change the pinned hash in the same
commit.
Since protocol 1.3 the fixture also carries reject_vectors (14 as of
protocol 1.4), which tests/wire_format_vectors.rs drives through
retrieve(), asserting the error the protocol names for each.
reject_envelope_slots_overclaim must fail with the pre-scan's own
summed-slot message, decode pre-scan: declares more elements than the input can back: any decoder, and the pre-scan's end-of-input check, rejects that
envelope too, so only the exact message proves the slot check ran.
src/read_allocation_probe.rs (unit tests only) bounds what the size-cap and
ratio reads allocate, with a positive control.
tests/wire_format_constructed.rs runs the fixture's constructed 32-bit
ratio-product vector natively and, in CI, on wasm32-unknown-unknown under
wasm-bindgen-test; tests/wire_format_vectors.rs decodes the constructed
bin16/bin32 and legacy array32 boundary vectors natively.
tests/decode_bounds_vectors.rs drives every reject and accept vector in the
protocol's test-vectors/decode-bounds.json (vendored the same way, at
tests/vectors/decode-bounds.json) through retrieve(). Each reject vector
must fail with the pre-scan's decode pre-scan: message prefix; failing
somewhere inside the decoder does not count.
Minimum Supported Rust Version
New releases support the latest stable Rust. This release requires Rust 1.91 or later: rust-version in Cargo.toml is the oldest toolchain CI tests, and any release may raise it when a dependency needs a newer one.
Contributing
User-facing docs in this repository follow CacheKit's shared rule on what belongs in them:
What belongs in these docs.
prek install (or pre-commit install) sets up hooks that reject internal references in README
files, docs/ and commit messages.
License
MIT License — see LICENSE for details.