diff --git a/CHANGELOG.md b/CHANGELOG.md index 7954064..8a6fac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to the CacheKit Protocol Specification. ## [Unreleased] +### SDK feature matrix — TypeScript `cache.secure.wrap()` now fails closed (LAB-513) + +- [`sdk-feature-matrix.md`](sdk-feature-matrix.md): the Encryption row "Does the + `secure` API enforce encryption?" flips ❌ → ✅ for TypeScript. Both + `cache.secure.wrap()` and `cache.withExecutionContext(ctx).secure.wrap()` now + throw `ConfigurationError` at wrap time on any instance without `encryption` + configured ([cachekit-ts#123](https://github.com/cachekit-io/cachekit-ts/pull/123)); + before, both were unconditional aliases for `wrap()`, so on an instance + without configured encryption a "secure" registration stored plaintext + (CWE-311) — on an encrypted instance they always encrypted. All three SDKs + now refuse a missing key on the secure entry point — py raises at decoration + time, rs `secure()` returns `Err`, ts throws at wrap time — with no opt-in to + run the secure entry point unencrypted in any of them; ts callers who want + plaintext call `wrap()` explicitly. The "Intent-preset + semantics" warning is rewritten to the enforced contract, the "cells that + reversed" summary and the Rust builder-stub cross-reference are updated to + match, and the stale `cache-core.ts:832` / `:873` / `:486`, `cache.ts:87` and + `intents-core.ts:240` references are replaced with current ones. + ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) - LZ4 compressed bytes are **not canonical** across conforming block encoders. diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index a1f64e9..d0f3e19 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -8,7 +8,7 @@ *Last updated: 2026-08-04 — LAB-1400 consolidation of ten open matrix PRs into one end-state. Every version-keyed claim is verified against the **published artifact** (registry metadata, and the `.crate`/`.tgz` contents where an embedded dependency version decides the answer), not against a repo branch — see [decisions/matrix-version-verification.md](decisions/matrix-version-verification.md) for why and how. Per-PR fold verdicts are in [CHANGELOG.md](CHANGELOG.md); per-row history is `git log sdk-feature-matrix.md`.* -*__Cells that reversed — check these if you built on them:__ Key rotation (py/rs ✅ → ❌ fleet-wide), Rust `::secure` preset and Rust sync support (both ✅ → do not exist), Builder API (py/ts ✅ → ❌), Hardware acceleration (rs ✅ → not re-exported, ts N/A → ❌), TypeScript Arrow (🔜 → ❌), Python's encrypted read path (documented fail-closed → **fail-open by default**), and `cache.secure.wrap()` in TypeScript (implied encryption → **no guarantee**). The TypeScript protocol-1.1 `bin` rollout also reversed twice in two days: it is **not** shipped on either ts path (per-artifact evidence in the [cachekit-core architecture note](#architecture-notes)).* +*__Cells that reversed — check these if you built on them:__ Key rotation (py/rs ✅ → ❌ fleet-wide), Rust `::secure` preset and Rust sync support (both ✅ → do not exist), Builder API (py/ts ✅ → ❌), Hardware acceleration (rs ✅ → not re-exported, ts N/A → ❌), TypeScript Arrow (🔜 → ❌), Python's encrypted read path (documented fail-closed → **fail-open by default**), and `cache.secure.wrap()` in TypeScript (implied encryption → no guarantee → **enforced since LAB-513: throws without encryption**). The TypeScript protocol-1.1 `bin` rollout also reversed twice in two days: it is **not** shipped on either ts path (per-artifact evidence in the [cachekit-core architecture note](#architecture-notes)).* @@ -68,7 +68,7 @@ | AAD v0x03 (cache_key binding) | ✅ | ✅ | ✅ | ❌ | | Key rotation | ❌ mismatch detection only⁵ | ❌⁵ | ❌ — nonce-exhaustion detection exists but is **unobservable** to the caller⁵ ⁸ | ❌ | | **Tamper / wrong-key failure mode** | ⚠️ **fail-OPEN by default** — warn + recompute; switchable with `CACHEKIT_ENCRYPTION_FAIL_CLOSED=true`¹⁸ | ✅ **Fails closed** — `decrypt(…)?` propagates (`client.rs:830`, `:847`), and `#[cachekit(secure)]` emits no fail-open arm (`cachekit-macros/src/lib.rs:439-451`) | ⚠️ **fail-OPEN on reads, silently drops writes, not switchable**⁸ | — | -| **Does the `secure` API enforce encryption?** | ✅ Raises without a key | ✅ `secure()` returns `Err` | ❌ **`cache.secure.wrap()` is an unconditional alias for `wrap()`** — silently caches plaintext on any instance not built by `createCache.secure()` (LAB-513, CWE-311); see [Intent-preset semantics](#intent-preset-semantics-parity-not-presence) | — | +| **Does the `secure` API enforce encryption?** | ✅ Raises without a key | ✅ `secure()` returns `Err` | ✅ **`cache.secure.wrap()` throws `ConfigurationError` at wrap time** on any instance without `encryption` configured — instance and `withExecutionContext(ctx)` view alike (LAB-513; before it, an unconditional alias for `wrap()`, so on an instance without configured encryption a "secure" registration cached plaintext, CWE-311); see [Intent-preset semantics](#intent-preset-semantics-parity-not-presence) | — | | Hardware acceleration detection | ✅ surfaced (`hardware_acceleration_enabled()`) | ⚠️ core-internal, not re-exported⁶ | ❌ not exposed⁶ | N/A | | Counter-based nonces | ✅ via Rust | ✅ | ✅ via NAPI (Rust) | ❌ use random | @@ -115,7 +115,7 @@ > > ¹⁹ **`cargo add cachekit-rs --features workers` does not compile.** Cargo features are additive, so that command keeps the default set — and the published 0.6.0 crate declares `default = ["cachekitio", "encryption", "l1", "reliability"]` while `src/lib.rs` carries `compile_error!` for `workers`×`l1` (moka needs std threads) *and* `workers`×`reliability` (retry/breaker timers need tokio `time`), plus `workers`×`redis`, `workers`×`memcached` and `workers`×`file`. The Workers build is therefore `--no-default-features --features workers,encryption,cachekitio`. > -> **Do not drop `encryption` from that list.** With the feature off, `CacheKitBuilder::encryption()` and `::encryption_from_bytes()` are compiled as **silent no-op stubs that return `Ok(self)`** (`crates/cachekit/src/client.rs:1019-1032`) — so the documented builder call succeeds, no error surfaces anywhere, and the cache stores **plaintext at rest**. Only `secure()` is `#[cfg]`-gated and fails loudly; the builder path is not. That is the same CWE-311 shape as the `cache.secure.wrap()` row above, reached by following a feature list instead of an API. Every ✅ in this document's Encryption table assumes `encryption` is enabled. +> **Do not drop `encryption` from that list.** With the feature off, `CacheKitBuilder::encryption()` and `::encryption_from_bytes()` are compiled as **silent no-op stubs that return `Ok(self)`** (`crates/cachekit/src/client.rs:1019-1032`) — so the documented builder call succeeds, no error surfaces anywhere, and the cache stores **plaintext at rest**. Only `secure()` is `#[cfg]`-gated and fails loudly; the builder path is not. That is the CWE-311 shape TypeScript's `cache.secure.wrap()` had before LAB-513 (row above), reached by following a feature list instead of an API. Every ✅ in this document's Encryption table assumes `encryption` is enabled. > > Verified in the published `cachekit-rs-0.6.0.crate`, not the branch. The ✅ is real — the invocation most readers would try is not, which is why it is stated on the cell rather than left to a compiler error. @@ -254,9 +254,9 @@ The four shared preset names configure **different things per SDK**. Each cell i | `CACHEKIT_MASTER_KEY` auto-enables encryption | **all presets** | only `CacheKit::from_env()` | only `createCache.secure()` | > [!WARNING] -> Preset names promise more parity than they deliver. The sharpest traps: Python preset entries **live forever** where Rust and TypeScript expire in 300–3 600 s; the master key is a **hex string** in py/ts but **raw bytes** in rs, so passing the same value across SDKs is a type error at best and a wrong key at worst; and the same `CACHEKIT_MASTER_KEY` activates encryption everywhere in Python, only in `from_env()` in Rust, and only in `.secure()` in TypeScript. All three do reject a *missing* key on the encrypted preset — Python raises, Rust returns `Err`, and `createCache.secure()` throws `ConfigurationError` (`intents-core.ts:240`). +> Preset names promise more parity than they deliver. The sharpest traps: Python preset entries **live forever** where Rust and TypeScript expire in 300–3 600 s; the master key is a **hex string** in py/ts but **raw bytes** in rs, so passing the same value across SDKs is a type error at best and a wrong key at worst; and the same `CACHEKIT_MASTER_KEY` activates encryption everywhere in Python, only in `from_env()` in Rust, and only in `.secure()` in TypeScript. All three do reject a *missing* key on the encrypted preset — Python raises, Rust returns `Err`, and `createCache.secure()` throws `ConfigurationError` (`intents-core.ts:257`). > -> **`cache.secure.wrap()` guarantees nothing in TypeScript (LAB-513).** It is an unconditional alias for `cache.wrap()` — `secure = { wrap: (fn, options) => this.wrap(fn, options) }` (`cache-core.ts:832`, mirrored at `:873` for `withExecutionContext`) — and every intent, `minimal` and `production` and `io` included, is typed as `SecureCache` (`cache.ts:87`). Encryption applies only where an encryption manager was configured (`if (this.encryption)`, `cache-core.ts:486`), so moving sensitive values behind `cache.secure.wrap` on a cache **not** built by `createCache.secure()` stores them as plaintext with no error, no warning, and no type error. Only `createCache.secure()` turns encryption on. Python and Rust have no equivalent trap: py raises and rs's `secure()` accessor returns `Err` without configured encryption. +> **`cache.secure.wrap()` fails closed in TypeScript (LAB-513).** Before LAB-513 it was an unconditional alias for `cache.wrap()` at both sites — the instance property and the `withExecutionContext(ctx)` view — so a "secure" registration on a cache without `encryption` configured — typically one **not** built by `createCache.secure()` — stored plaintext with no error, no warning, and no type error. Both sites now route through one private guard, `CacheImpl.secureWrap` (`cache-core.ts:1207`), that throws `ConfigurationError` at wrap time — not on first call — when no encryption manager is configured (`this.encryption === null`). The type surface is unchanged: every intent, `minimal` and `production` and `io` included, is still typed `SecureCache` (`types/cache.ts:344`), so `.secure` is *present* on all of them and *refuses* on the unencrypted ones. There is deliberately no option to run it unencrypted — a caller who wants plaintext calls `wrap()`. This is the py/rs contract: py raises at decoration time (`decorators/intent.py:208`) and rs's `secure()` returns `Err` (`client.rs:664`) without configured encryption. ---