Skip to content

Latest commit

 

History

History
208 lines (163 loc) · 9.78 KB

File metadata and controls

208 lines (163 loc) · 9.78 KB

Security Policy

Supported Versions

Version Supported
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

What to Include

  • Description of the vulnerability
  • Steps to reproduce
  • Potential impact
  • Suggested fix (if any)

Response Timeline

Stage Timeline
Initial response 48 hours
Triage & assessment 7 days
Fix development 14-30 days
Public disclosure After fix released

Security Model

Cryptographic Guarantees

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

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)
  • 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:

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_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 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:

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.

Dependencies

Security-critical dependencies are audited via cargo-deny:

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. 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/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.