Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ jobs:
# Mutation suite first, same doctrine as the version-floor guard below:
# prove the fail-closed guards still fail before trusting the verify.
python3 tools/test_wire_format_reference.py
python3 tools/test_decode_bounds_reference.py
python3 tools/interop-reference.py verify
python3 tools/interop-v2-reference.py verify
python3 tools/encryption-verify.py
python3 tools/wire-format-reference.py verify
python3 tools/decode-bounds-reference.py verify
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Python reference verify (optional deps — AES-GCM seal + msgpack third-encoder + lz4 C-implementation conformance)
run: |
Expand All @@ -42,6 +44,7 @@ jobs:
python3 tools/interop-v2-reference.py verify
python3 tools/encryption-verify.py --require-seal
python3 tools/wire-format-reference.py verify --require-extras
python3 tools/decode-bounds-reference.py verify --require-extras

- name: JS cross-check (independent encoder + @noble/hashes + WebCrypto)
run: |
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to the CacheKit Protocol Specification.

## [Unreleased]

### Interop mode — untrusted-decode bounds pinned as a cross-SDK invariant (LAB-2503)

- New [`spec/interop-mode.md` → Decode bounds](spec/interop-mode.md#decode-bounds):
readers MUST bound nesting depth (≥ 32, ≤ 1024), MUST NOT pre-allocate beyond
what the input can back (Σ declared slots ≤ input bytes − 1), and MUST fail
closed with a catchable error. Follow-up to the LAB-2487 measurements.
- New [`test-vectors/decode-bounds.json`](test-vectors/decode-bounds.json)
(13 reject + 2 accept) with [`tools/decode-bounds-reference.py`](tools/decode-bounds-reference.py)
and its mutation suite; to be vendored and CI-executed by
[cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) and
[cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (both open).
- [`spec/wire-format.md` → Security Limits](spec/wire-format.md#security-limits)
cross-references the rules for the envelope bytes and the payload inside them.
- The single shared depth value stays [protocol#20](https://github.com/cachekit-io/protocol/issues/20)'s
open item.

### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751)

- LZ4 compressed bytes are **not canonical** across conforming block encoders.
Expand Down
6 changes: 3 additions & 3 deletions sdk-feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,20 +280,20 @@ its spec:
| Requirement | Python | Rust | TypeScript | PHP |
| :--- | :---: | :---: | :---: | :---: |
| Key generation (Blake2b) | ✅ Compliant | N/A auto mode¹⁴ — interop/v1 keygen ✅ merged ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)); `#[cachekit]` mints interop keys ([#35](https://github.com/cachekit-io/cachekit-rs/pull/35)) | ✅ Compliant | ⚠️ Untested |
| Wire format (ByteStorage) | ✅ Compliant¹⁵ | ✅ Canonical (`cachekit-core`) — unused for stored values¹⁵ | ✅ Compliant | ⚠️ Untested |
| Wire format (ByteStorage) | ✅ Compliant¹⁵ — envelope-bytes decode-bounds pre-scan (LAB-2503) pending verification | ✅ Canonical (`cachekit-core`) — unused for stored values¹⁵ | ✅ Compliant — envelope-bytes decode-bounds pre-scan (LAB-2503) pending verification | ⚠️ Untested |
| Storage container (auto mode)¹⁵ | CK v3 frame (Python-internal) | Plain MessagePack (`rmp` named) — no envelope | Bare ByteStorage envelope (default) | — |
| Encryption (AES-256-GCM) | ✅ Compliant | ✅ Canonical (cachekit-core) | ✅ Compliant | ⚠️ Untested |
| AAD v0x03 | ✅ Compliant (5 components — every auto serializer appends `original_type`; interop mode is the sole 4-component path) | ✅ Compliant (4 components) | ✅ Compliant (4 components) | ❌ Not implemented |
| SaaS API | ✅ Compliant | ✅ Compliant (CachekitIO backend) | ✅ Compliant | ❌ Not implemented |
| Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33) | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors | ⚠️ Pending |
| Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack); `decode-bounds.json` vendored + CI-executed pending [cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) (LAB-2503) | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33); `decode-bounds.json` vendored + CI-executed pending [cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (LAB-2503) | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors; decode bounds enforced ([#112](https://github.com/cachekit-io/cachekit-ts/pull/112)), `decode-bounds.json` not yet vendored | ⚠️ Pending |
| Interop mode ([spec](spec/interop-mode.md), opt-in) | ✅ Released — PyPI 0.14.0+¹⁷ ([#220](https://github.com/cachekit-io/cachekit-py/pull/220)) | ✅ Released — crates.io 0.4.0+ ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)) | ✅ Released — npm 0.1.3+ ([#71](https://github.com/cachekit-io/cachekit-ts/pull/71)) | ❌ Not implemented |

> [!NOTE]
> ¹⁴ "N/A" for Rust *auto-mode* key generation means `cachekit-rs` implements no auto-mode key format: `get`/`set` take caller-supplied keys. The `#[cachekit]` macro mints **interop/v1** keys via `interop_key` — required, compile-time-validated `interop = "operation"` and `namespace` attributes, byte-identical across SDKs ([cachekit-rs#35](https://github.com/cachekit-io/cachekit-rs/pull/35) / LAB-424; keygen itself merged in [#33](https://github.com/cachekit-io/cachekit-rs/pull/33)). The legacy RFC §3.1.5 keygen (`key::generate_cache_key`, `{namespace}:{blake2b256-hex}` — matched no protocol format, and WAS live in every `#[cachekit]` expansion despite the audit's "unused" premise, a proc-macro grep miss) is deleted outright in #35; upgrading is a full cache invalidation for `#[cachekit]` users. `cachekit-core` is a protocol primitive library with no keygen.
>
> ¹⁵ Auto-mode **stored bytes** are SDK-internal and differ per SDK — see [wire-format.md → SDK Storage Containers](spec/wire-format.md#sdk-storage-containers-auto-mode). Python stores the ByteStorage envelope *inside* its CK v3 frame; `cachekit-rs` does not use the envelope for values at all (it uses `cachekit-core` only for encryption). Cross-SDK value compatibility is exclusively an [interop-mode](spec/interop-mode.md) property (protocol#11).
>
> ¹⁶ "Test vectors in CI" = vectors the SDK's own default CI executes. Beyond the SDKs, this repo's `verify.yml` CI-verifies `interop-mode.json`, `encryption.json`, `python-frame.json`, `file-backend.json` ([`tools/file-backend-reference.py`](tools/file-backend-reference.py)), and — since LAB-423 — `wire-format.json` ([`tools/wire-format-reference.py`](tools/wire-format-reference.py)) against reference implementations. `cache-keys.json` (regenerated by cachekit-py v0.12.0, byte-identical to the v0.5.0 originals) is vendored and CI-verified in cachekit-py since [cachekit-py#229](https://github.com/cachekit-io/cachekit-py/pull/229) (LAB-425).
> ¹⁶ "Test vectors in CI" = vectors the SDK's own default CI executes. Beyond the SDKs, this repo's `verify.yml` CI-verifies `interop-mode.json`, `encryption.json`, `python-frame.json`, `file-backend.json` ([`tools/file-backend-reference.py`](tools/file-backend-reference.py)), and — since LAB-423 — `wire-format.json` ([`tools/wire-format-reference.py`](tools/wire-format-reference.py)), and — since LAB-2503 — `decode-bounds.json` ([`tools/decode-bounds-reference.py`](tools/decode-bounds-reference.py), `verify` in both the stdlib and the optional-deps legs) against reference implementations. `cache-keys.json` (regenerated by cachekit-py v0.12.0, byte-identical to the v0.5.0 originals) is vendored and CI-verified in cachekit-py since [cachekit-py#229](https://github.com/cachekit-io/cachekit-py/pull/229) (LAB-425).
>
> ¹⁷ Version cells are **floors** (`X+`), not snapshots — they stay true as new versions publish; check the registry for the current release. Python's floor is the first *installable* one: interop merged under the `v0.13.0` tag, but neither `0.12.0` nor `0.13.0` was ever published to PyPI, so `0.14.0` is the earliest PyPI release containing interop mode. Do not "correct" this to 0.13.0 from the cachekit-py changelog alone.

Expand Down
53 changes: 53 additions & 0 deletions spec/interop-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- [Encryption in Interop Mode](#encryption-in-interop-mode)
- [SaaS Considerations](#saas-considerations)
- [SDK Implementation Requirements](#sdk-implementation-requirements)
- [Decode bounds](#decode-bounds)
- [Design Decisions](#design-decisions)
- [Test Vectors](#test-vectors)

Expand Down Expand Up @@ -438,6 +439,47 @@ strings** (TypeScript has no UUID type): callers MUST use the lowercase hyphenat
form, or `"550E8400-…"` from TS will silently miss the key a Python `uuid.UUID`
argument produced.

### Decode bounds

Interop values are read from a backend the SDK does not control, so every decoder
is an untrusted-input parser. A MessagePack collection header costs 1–5 bytes but
may declare up to 2³²−1 elements, and an eager decoder pre-allocates the container
*before* decoding its children; depth-first decoding stacks those allocations, so a
few KB of nested headers can drive hundreds of MB of transient heap (measured
15 KB → ~400 MB in `@msgpack/msgpack` 3.1.3; 10 KB → 67 MB in `msgpack-python`
1.2.1 with `array32` headers claiming `len(input)` elements). A reader MUST
therefore:

1. **Bound nesting depth.** The bound MUST be at least 32 and MUST NOT exceed 1024.
(Today: TypeScript 100, Rust 100, Python 1024. A single shared value is
[protocol#20](https://github.com/cachekit-io/protocol/issues/20)'s open item;
until it is ratified, writers SHOULD keep values within 32 levels.)
2. **Never pre-allocate beyond what the input can back.** Every declared element or
byte needs at least one input byte, so a structurally incomplete document
(Σ declared slots > input bytes − 1) MUST be rejected *without* materialising it.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
A map pair counts as two slots (key + value). Every per-header term and the running
sum MUST be computed in at least 64 bits or with checked/saturating arithmetic, and
an overflow is itself a rejection: two `array32` headers already exceed 2³², and a
32-bit accumulator that wraps to a small value passes the budget
(`array32_sum_wraps_u32` and `map32_half_claim_wraps_u32_mul` pin the shapes).
Do not assume a decoder is lazy: `rmp-serde` reads str/bin lazily but serde's
`Vec<T>` visitor still pre-allocates up to 1 MiB per collection from the
declared length. A header-only structural walk before decoding (the pre-scan in
`cachekit-ts`, `Unpacker.skip()` in `cachekit-py`, `check_structure` in
`cachekit-rs`) is sufficient.
3. **Fail closed, catchably.** Rejection surfaces as a decode error the SDK read
path turns into a cache miss — never an uncaught crash or an OOM abort.

These bounds are SDK-owned invariants, not library defaults: each SDK pins them
explicitly and regression-tests them, so a decoder dependency bump cannot silently
re-open the amplifier.
[`test-vectors/decode-bounds.json`](../test-vectors/decode-bounds.json) pins the
bytes every decoder MUST reject (13) and MUST accept (2); the same rules apply to
any other untrusted MessagePack decode in an SDK (auto-mode payloads after the
envelope is unwrapped, invalidation events). The 40× residual — a *legal* payload
still materialises far more than its byte size in language objects — is bounded by
each SDK's input-size cap, not by these rules.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

---

## Design Decisions
Expand Down Expand Up @@ -472,6 +514,17 @@ not re-litigated by accident.
| `encryption_vectors` | 1 | Full HKDF-SHA256 → AES-256-GCM round-trip over plain-msgpack plaintext with the interop AAD (fixed nonce; decrypt-verified) |
| `error_vectors` | 9 | Inputs that MUST be rejected (NaN, +Inf and −Inf as independent vectors, int overflow/underflow, naive datetime, bad segments incl. trailing newline). The `error` text is a maintainer note, not a normative message |

[`test-vectors/decode-bounds.json`](../test-vectors/decode-bounds.json) (see
[Decode bounds](#decode-bounds)) adds 13 `reject_vectors` (nested-header bombs,
over-claiming `array32`/`map32`/`bin32`/`str32` headers, a truncated array and map,
two shapes that wrap 32-bit slot arithmetic) and 2
`accept_vectors` (32-deep nesting, a fully backed `array16`) that every SDK's
untrusted decoder MUST honour; `tools/decode-bounds-reference.py verify` checks the
file against its recipes and, when `msgpack-python` is installed, that the real
decoder rejects/accepts each vector (rejection only — msgpack-python's default limits
reject them, which proves each vector trips a stock decoder's limits; each SDK's explicit bound and
no-pre-allocation guard are tested in that SDK, not here).

Inputs use a tagged-JSON convention (`{"$set": …}`, `{"$float": "2.0"}`,
`{"$int": "…"}`, `{"$datetime": "…"}`, `{"$uuid": "…"}`, `{"$bytes": "<hex>"}`)
documented in the file header, because JSON alone cannot express sets, bytes, floats
Expand Down
16 changes: 11 additions & 5 deletions spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,9 @@ bytes are therefore

- A conforming reader MUST decompress every pinned vector's `compressed_data`
to its pinned input, **and MUST enforce [Retrieve Flow](#retrieve-flow) steps
4, 5 and 9 while doing so.** Read-side conformance is not "the vectors pass":
2, 4, 5 and 9 while doing so.** Read-side conformance is not "the vectors pass":
every pinned vector is well-formed and declares a truthful `original_size`, so
they evidence **none** of those bounds, and a reader that omits all three
they evidence **none** of those bounds, and a reader that omits all four
decompresses all of them successfully. The vectors prove decode
interoperability; the bounds in [Security Limits](#security-limits) are a
separate, non-negotiable obligation that no fixture can demonstrate.
Expand Down Expand Up @@ -376,8 +376,13 @@ let checksum: [u8; 8] = xxh3_64(&original_data).to_be_bytes();
> Additionally, a decoder MUST validate any declared MessagePack `bin`/array
> length header against the remaining input bytes **before** allocating for it —
> a 5-byte `bin32` header can otherwise declare a 4 GiB allocation from a
> ~30-byte envelope. (Slice-based decoders such as `rmp-serde` satisfy this
> inherently; readers that pre-allocate from length fields must check.)
> ~30-byte envelope. No decoder satisfies this inherently — even slice-based
> ones pre-allocate collections from declared lengths. The envelope bytes *and*
> the payload inside them
> are both untrusted MessagePack — decode each under the depth and allocation
> rules in [interop-mode.md → Decode bounds](interop-mode.md#decode-bounds),
> pinned by `test-vectors/decode-bounds.json`, running the structural pre-scan
> before materialising `StorageEnvelope`.

| Limit | Value | Purpose |
| :--- | ---: | :--- |
Expand Down Expand Up @@ -439,7 +444,8 @@ Input: raw_data (bytes), format (string, default "msgpack")
Input: envelope_bytes

1. Validate: envelope_bytes.length <= 512 MiB
2. Deserialize: envelope = msgpack_decode(envelope_bytes) as StorageEnvelope
2. Deserialize: pre-scan envelope_bytes (decode bounds, see Security Limits), then
envelope = msgpack_decode(envelope_bytes) as StorageEnvelope

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Violates team rule 'Avoid unsafe type assertions': Detect cases of unsafe type assertions. These do not perform runtime checks and can lead to unexpected runtime errors. Recommend using proper type guards instead.

Prompt for LLM

File spec/wire-format.md:

Line 448:

Violates team rule 'Avoid unsafe type assertions': Detect cases of unsafe type assertions. These do not perform runtime checks and can lead to unexpected runtime errors. Recommend using proper type guards instead.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

// accept BOTH element[0] encodings: bin AND array-of-ints
3. Validate: envelope.compressed_data.length <= 512 MiB
4. Validate: envelope.original_size <= 512 MiB
Expand Down
Loading
Loading