| Version | Supported |
|---|---|
| 0.1.x | Yes |
Do not open a public GitHub issue for security vulnerabilities.
Instead, please report security issues via:
- Email: security@cachekit.io
- GitHub Security Advisories: Report a vulnerability
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
| Stage | Timeline |
|---|---|
| Initial response | 48 hours |
| Triage & assessment | 7 days |
| Fix development | 14-30 days |
| Public disclosure | After fix released |
| Component | Algorithm | Notes |
|---|---|---|
| Encryption | AES-256-GCM | AEAD via ring crate |
| Key Derivation | HKDF-SHA256 | RFC 5869 compliant |
| Integrity | xxHash3-64 | Non-cryptographic (corruption detection) |
| Nonce | Counter + Random IV | Unique per encryption |
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)
- Memory disclosure:
zeroizeon 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
StorageEnvelope::extract bounds LZ4 decompression before calling
lz4_flex::decompress, so a forged envelope cannot expand without limit:
| Limit | Value | Enforced on |
|---|---|---|
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. Keep lz4_flex's default safe-decode feature on: it
decodes into a zero-filled fixed-length buffer, where the non-safe decoder uses
with_capacity + set_len.
original_sizedoes 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 to1000 × compressed_data.len()before the LZ4 stream is validated. That is allocation amplification within the bound, not a bypass of it. Note thatByteStorage::validate()reaches the same allocation — it callsextract()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 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.
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:
| Rule | Bound |
|---|---|
| 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.
Security-critical dependencies are audited via cargo-deny:
cargo deny check advisoriesSee deny.toml for the full security policy.
A CycloneDX 1.6 SBOM is generated by cargo-sbom during publish and attested
against the packaged crate via
actions/attest-sbom. The attestation
is the verifiable artifact — verify it against the crate as published:
# 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/bomNote 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.
No vulnerabilities have been disclosed yet.