# Security Policy
## Supported Versions
| 0.1.x | Yes |
## Reporting a Vulnerability
**Do not open a public GitHub issue for security vulnerabilities.**
Instead, please report security issues via:
1. **Email**: security@cachekit.io
2. **GitHub Security Advisories**: [Report a vulnerability](https://github.com/cachekit-io/cachekit-core/security/advisories/new)
### What to Include
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### Response Timeline
| Initial response | 48 hours |
| Triage & assessment | 7 days |
| Fix development | 14-30 days |
| Public disclosure | After fix released |
## Security Model
### Cryptographic Guarantees
| Encryption | AES-256-GCM | AEAD via `ring` on native, `aes-gcm` on wasm32 |
| Key Derivation | HKDF-SHA256 | RFC 5869 compliant |
| Integrity | xxHash3-64 | Non-cryptographic (corruption detection) |
| Nonce | Counter + Random IV | Unique per encryption |
### Threat Model
This crate protects against:
- **Data tampering**: GCM authentication tags (when encryption enabled); xxHash3 detects accidental corruption only
- **Data disclosure**: AES-256-GCM encryption (when enabled)
- **Key compromise isolation**: HKDF domain separation per tenant
- **Decompression bombs**: Size limits + ratio validation (see [Decompression limits](#decompression-limits))
- **Memory disclosure**: `zeroize` on drop for key material
This crate does **not** protect against:
- Side-channel attacks on the host system
- Compromise of the master key
- Denial of service via resource exhaustion (partial protection only)
- Attacks requiring physical access
### Decompression limits
`StorageEnvelope::extract` bounds LZ4 decompression **before** calling
`lz4_flex::decompress`, so a forged envelope cannot expand without limit:
| `MAX_COMPRESSED_SIZE` | 512 MiB | `compressed_data.len()` |
| `MAX_UNCOMPRESSED_SIZE` | 512 MiB | declared `original_size` |
| `MAX_COMPRESSION_RATIO` | 1000:1 | `original_size` vs `compressed_data.len()` |
The same `MAX_COMPRESSED_SIZE` constant also bounds the *serialized* envelope
before MessagePack deserialization — but that check lives in
`ByteStorage::retrieve` and `ByteStorage::validate`, not on `StorageEnvelope`.
`StorageEnvelope` is public with public fields, so a caller who deserializes it
directly gets no such bound and must impose one.
The ratio product is computed in `u64` via `checked_mul`, and overflow is
treated as a bomb. Zero-length compressed data is rejected unconditionally,
including when the envelope declares `original_size == 0`.
`lz4_flex` returns `OutputTooSmall` rather than growing past the allocation, so
the decompressed output is bounded by `min(512 MiB, 1000 × compressed_data.len())`
regardless of what the envelope claims. `extract` then re-checks the produced
length, because a decompressor's size argument sizes a buffer; it never asserts
the decoded length. `Cargo.toml` turns `lz4_flex`'s default features off and
sets `safe-decode` and `checked-decode` explicitly, so they no longer depend on
upstream defaults. Keep `safe-decode` on: decode input is untrusted, and it
selects the decoder that runs under `forbid(unsafe_code)` and decodes into a
zero-filled fixed-length buffer, where the non-safe decoder uses
`with_capacity` + `set_len` (RUSTSEC-2026-0041 was a bug in that block
decoder).
The encoder is `lz4_flex`'s unsafe one (`safe-encode` off) on 64-bit targets.
It runs the same matching algorithm as the safe encoder and writes the same
bytes, with unchecked pointer reads and writes (into the output's uninitialised
capacity, followed by `set_len`) in place of bounds-checked slice access, which
makes each compression cheaper. Its input is the caller's value, which can
carry attacker-chosen bytes, so the `byte_storage_compress` fuzz target builds
`lz4_flex` with the same features (`fuzz/Cargo.toml`; a default-features entry
there would unify the safe encoder back in).
Every target narrower than 64 bits (i686, armv7, 32-bit Windows, wasm32) keeps
the safe encoder through a target-specific dependency. On wasm32 the value, the
encoder and any key material share one linear memory, and the fuzzers run on
64-bit targets only, so the unsafe encoder is unexercised there. The encoder
sizes its output as `16 + 4 + input_len * 110 / 100`. `lz4_flex` 0.12 computed
that in `usize`, which overflows on a 32-bit target for inputs above
39,045,157 B, below this crate's 512 MiB input cap: a debug build panicked on
the multiply, and a release build panicked when the compressed output outgrew
the wrapped bound. This crate requires 0.13, which computes the bound in `u64`;
the `wasm32` CI job stores and retrieves an input one byte past that boundary.
Below 64 bytes, `StorageEnvelope::new` does not call the encoder at all. It
writes the input as one literals-only LZ4 block, which every LZ4 block decoder
reads.
- **`original_size` does not act as a bound.** It is attacker-controlled on any
backend an attacker can write to. It *does* size the allocation, but only
within the absolute and ratio limits already checked above — so a forged
envelope can still make a reader allocate up to `1000 × compressed_data.len()`
before the LZ4 stream is validated. That is allocation amplification within
the bound, not a bypass of it. Note that `ByteStorage::validate()` reaches the
same allocation — it calls `extract()` and discards the result — so it is
*not* a cheap structural pre-screen for untrusted envelopes.
- **xxHash3-64 is not a control here.** It is unkeyed, so anyone who can
forge an envelope recomputes it. It detects accidental corruption, not
forgery. Authentication comes from AES-256-GCM, and only for secure caches.
**The ceiling is server-class.** `ByteStorage::retrieve` holds the serialized
envelope, the deserialized `compressed_data` copy and the decompressed output at
the same time, so a single call can peak well above 512 MiB — up to roughly
1.5 GiB at the limits. That does *not* fit a constrained runtime: a Cloudflare
Workers isolate has ~128 MiB, so a payload well inside these limits can still
exhaust it, and on `wasm32` the allocation is an eager `memory.grow` that needs
no valid LZ4 stream behind it. On `wasm32` that is worse than a spike: linear
memory never shrinks, so a single large extract permanently raises the
isolate's floor for every subsequent request it serves. Deployments on
constrained runtimes must bound payload size at the caller. Making these
constants environment-aware or configurable is tracked as a follow-up.
**Test coverage.** The three checks live in one private function,
`check_decompression_bound`, which `extract` calls before it allocates. At merge
time, the unit tests in `src/byte_storage.rs` and the integration tests in
`tests/byte_storage_tests.rs` enforce the bound: deleting any one of the three
checks fails at least one of them. The protocol's wire-format reject vectors
(`tests/wire_format_vectors.rs`) drive the size-cap, ratio and zero-length cases
through `retrieve` and assert the error each must raise. For the size-cap and
ratio vectors, a unit test (`src/read_allocation_probe.rs`) also counts every
byte the read requests from the global allocator and fails if the count reaches
`original_size`, so a reader that reserves its output before the checks fails
even when it raises the right error. A positive control runs the reader's own
decoder at `original_size` inside `extract`, before the checks, and shows the
probe catches it. The product's width is tested on a 32-bit target: CI runs the
protocol's `envelope_ratio_product_wraps_32_bits` vector on `wasm32`, where a
pointer-width product would wrap and reject it. The `compression_bomb` fuzz target
(`fuzz/fuzz_targets/compression_bomb.rs`) computes one expected result for each
call it makes to `extract` and `retrieve` and requires that exact result. It
builds envelopes at the 512 MiB limits that only one check rejects, so deleting
any one check makes it fail. At pull-request time the target is only built and
smoke-run. Each scheduled or on-demand deep run uploads its corpus as an
artifact kept for 90 days, and the next run on the same branch starts from the
newest one. With no such artifact it warns and starts from an empty corpus. Two
Kani proofs, `verify_decompression_bound_size_caps` and
`verify_decompression_bound_ratio`, check `check_decompression_bound` over every
(compressed length, `original_size`) pair against limits written as literals, so
an inverted comparison or a changed constant fails them. Kani runs on the
schedule and on manual dispatch, not on pull requests. These Kani statements
cover only those two proofs.
### Envelope decode bounds
`ByteStorage::retrieve` and `ByteStorage::validate` decode envelope bytes that
come from a backend the caller may not control. Before `rmp_serde` materialises
a `StorageEnvelope`, both run a header-only structural pre-scan over those bytes
(protocol `spec/wire-format.md` → Retrieve Flow, step 2; the bounds themselves
are `spec/interop-mode.md` → Decode bounds). The pre-scan rejects:
| Nesting depth | 100 levels; every array or map header on a path counts, an empty one included |
| Declared slots | pending collection elements never exceed the bytes left to back them; str/bin/ext lengths never exceed the bytes left |
| Framing | the reserved marker `0xc1`, and input that ends before the document is complete |
A legitimate envelope nests two levels deep, so the depth bound only ever
rejects forged input. It still matters: serde's derive skips an unknown map key
with a recursive `IgnoredAny`, so without the pre-scan the input, not this
crate, would set how deep the decode recurses. All counts are `u64`, and the
walk skips str/bin/ext payloads by offset: it allocates one `u64` per open
collection and nothing proportional to a declared length.
A rejection is `ByteStorageError::DeserializationFailed` with a message that
starts with `decode pre-scan: `, the same variant as any other bytes that do
not decode as an envelope, so bindings that map on the variant see no change.
Trailing bytes after the envelope are still ignored, as before.
`tests/decode_bounds_vectors.rs` drives every vector in the protocol's
`test-vectors/decode-bounds.json` (vendored sha256-pinned in `tests/vectors/`)
through `retrieve`, and asserts the pre-scan's message prefix, not merely that
the call fails. As with the size bound above, a caller that deserializes
`StorageEnvelope` directly bypasses the pre-scan and must impose its own.
The walk itself is public as `check_msgpack_structure(bytes, max_depth)`, so a
caller that decodes untrusted MessagePack outside `ByteStorage` can apply the
same rules at its own depth bound. It needs no optional feature. It returns a
`MsgpackStructureError` whose `Display` is the bare reason, with no prefix, and
it does not enforce the protocol's `32..=1024` range on `max_depth`: choosing
the bound is the caller's job. Neither does it reject trailing bytes:
`msgpack_document_len(bytes, max_depth)` runs the same walk, with the same
errors, and returns the document's length, so a caller that must consume
exactly one document rejects any input whose length differs from it.
### Dependencies
Security-critical dependencies are audited via `cargo-deny`:
```bash
cargo deny check advisories
```
See `deny.toml` for the full security policy.
### Software Bill of Materials
A CycloneDX 1.6 SBOM is generated by `cargo-sbom` during publish and attested
against the packaged crate via
[`actions/attest-sbom`](https://github.com/actions/attest-sbom). The attestation
is the verifiable artifact — verify it against the crate as published:
```bash
# Download the published crate, then verify the SBOM attestation against it.
curl -sSLO https://static.crates.io/crates/cachekit-core/cachekit-core-0.4.0.crate
gh attestation verify cachekit-core-0.4.0.crate --repo cachekit-io/cachekit-core \
--predicate-type https://cyclonedx.org/bom
```
Note the predicate type carries no version suffix: `actions/attest-sbom` records
CycloneDX as `https://cyclonedx.org/bom` regardless of spec version (the version
lives in the document's own `specVersion`). Provenance is attested separately
under `https://slsa.dev/provenance/v1` against the same subject.
GitHub releases for this repository carry no SBOM file as a downloadable asset.
Immutable releases are enabled here, which seals a release's assets at publish
time, so an SBOM cannot be attached after the fact. Use the attestation above.
## Vulnerability Disclosure History
No vulnerabilities have been disclosed yet.