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
5 changes: 3 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Vendored protocol test vectors are byte-exact artifacts, sha256-pinned by
# tests/wire_format_vectors.rs — never let git rewrite their line endings
# (windows runners set core.autocrlf=true and would break the pin).
# tests/wire_format_vectors.rs and tests/decode_bounds_vectors.rs — never let
# git rewrite their line endings (windows runners set core.autocrlf=true and
# would break the pin).
tests/vectors/* -text
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,19 @@ Malicious payloads claiming `original_size: 500GB` with 100 bytes of data are re

</details>

<details>
<summary><strong>Envelope Decode Bounds</strong></summary>

`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: `. See [`SECURITY.md`](SECURITY.md#envelope-decode-bounds).

</details>

---

## Architecture
Expand All @@ -256,6 +269,7 @@ cachekit-core/
├── src/
│ ├── lib.rs # Public API exports
│ ├── byte_storage.rs # LZ4 + xxHash3 storage envelope
│ ├── msgpack_bounds.rs # Structural pre-scan run before the envelope decode
│ ├── checksum.rs # Standalone xxHash3 checksum/verify primitive (feature = "checksum")
│ ├── metrics.rs # Operation timing & statistics
│ │
Expand Down Expand Up @@ -351,6 +365,12 @@ including bin16/bin32 width headers. The fixture is vendored at
it, re-copy from the protocol repo and change the pinned hash in the same
commit.

`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
Expand Down
32 changes: 32 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,38 @@ from an empty corpus. The Kani harnesses never run on pull requests, never
execute `StorageEnvelope::extract`, and cannot detect a wrong predicate. Treat both as
smoke checks, not as verification of the bound.

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

### Dependencies

Security-critical dependencies are audited via `cargo-deny`:
Expand Down
29 changes: 25 additions & 4 deletions src/byte_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,16 +218,23 @@ impl ByteStorage {
/// Retrieve and validate stored bytes
///
/// Returns (original_data, format_identifier)
///
/// # Errors
///
/// `envelope_bytes` is untrusted. Before it is decoded, a structural
/// pre-scan bounds its nesting depth and rejects any header that declares
/// more than the input can back (protocol Retrieve Flow, step 2). A
/// pre-scan rejection is `DeserializationFailed` whose message starts with
/// `decode pre-scan: `, the same variant as any other bytes that do not
/// decode as a `StorageEnvelope`.
#[cfg(all(feature = "compression", feature = "checksum", feature = "messagepack"))]
pub fn retrieve(&self, envelope_bytes: &[u8]) -> Result<(Vec<u8>, String), ByteStorageError> {
// Security: Check envelope size before deserializing
if envelope_bytes.len() > MAX_COMPRESSED_SIZE {
return Err(ByteStorageError::InputTooLarge);
}

// Deserialize envelope
let envelope: StorageEnvelope = rmp_serde::from_slice(envelope_bytes)
.map_err(|e| ByteStorageError::DeserializationFailed(e.to_string()))?;
let envelope = decode_envelope(envelope_bytes)?;

// Time decompression and checksum operations (wasm32: Instant unavailable, use 0)
#[cfg(not(target_arch = "wasm32"))]
Expand Down Expand Up @@ -282,7 +289,7 @@ impl ByteStorage {
return false; // Invalid due to size limit
}

match rmp_serde::from_slice::<StorageEnvelope>(envelope_bytes) {
match decode_envelope(envelope_bytes) {
Ok(envelope) => envelope.extract().is_ok(),
Err(_) => false,
}
Expand Down Expand Up @@ -318,6 +325,20 @@ impl Default for ByteStorage {
}
}

/// Decode untrusted envelope bytes: structural pre-scan first, then the typed
/// decode. Serde's derive skips an unknown map key with `IgnoredAny`, which
/// recurses, so the depth bound has to hold before `rmp_serde` sees the bytes.
#[cfg(all(feature = "compression", feature = "checksum", feature = "messagepack"))]
fn decode_envelope(envelope_bytes: &[u8]) -> Result<StorageEnvelope, ByteStorageError> {
use crate::msgpack_bounds::{check_msgpack_structure, MAX_DEPTH};

check_msgpack_structure(envelope_bytes, MAX_DEPTH).map_err(|what| {
ByteStorageError::DeserializationFailed(format!("decode pre-scan: {what}"))
})?;
rmp_serde::from_slice(envelope_bytes)
.map_err(|e| ByteStorageError::DeserializationFailed(e.to_string()))
}

#[cfg(all(
test,
feature = "compression",
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pub use checksum::{checksum, verify_checksum};

// Core byte storage layer
pub mod byte_storage;
#[cfg(all(feature = "compression", feature = "checksum", feature = "messagepack"))]
mod msgpack_bounds;
pub use byte_storage::{ByteStorage, StorageEnvelope};

// Encryption module (feature-gated)
Expand Down
Loading
Loading