feat(encryption): keyring rotation — previous_master_keys + fingerprint selection (LAB-684) - #261
Conversation
…ingerprint selection (LAB-684) Implements protocol spec/encryption.md → 'Key Rotation (Keyring)' for cachekit-py, building on the cachekit-core 0.5.0 Keyring helper (LAB-683): - Config: CachekitConfig.previous_master_keys (list[SecretStr], env CACHEKIT_PREVIOUS_MASTER_KEYS, comma-separated hex). Load-time validation: cap of 3 (rejected, never truncated), per-key requirements identical to master_key (hex, >=32 bytes), and the forward-only subset check — the current master_key re-appearing in the decrypt-only list is rejected (re-promotion would resume a used AES-GCM nonce budget). Redacted in repr/str/get_safe_repr like master_key. - EncryptionWrapper decrypt: fingerprint-based keyring selection. The frame's key_fingerprint is matched against each entry's HKDF-derived per-tenant encryption-key fingerprint (never the master-key fingerprint), current key first. A match is binding: the matched entry is the only key used and its authentication failure is terminal. No match preserves the pre-keyring fail_closed/fail-open semantics byte-for-byte; the current-key hot path keeps the cached derived tenant keys (no per-read HKDF). - FFI: new Keyring binding (construction, per-tenant fingerprints, decrypt_at). Master-key material enters once at config ingestion and never returns to Python; keyring material zeroizes on drop in Rust. - Deletes the dead KeyRotationState PyO3 binding at all three sites (LAB-275 trust bug — zero Python callers since inception); the cachekit-core 0.5.0 bump makes the removal compiler-enforced. - Docs: README, docs/configuration.md, zero-knowledge-encryption.md rotation sections rewritten to the real keyring surface — including removal of the never-implemented CACHEKIT_MASTER_KEY_ROTATION env var the docs invented. Doctests cover the rotation round-trip and the forward-only rejection.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 11 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
WalkthroughThe change adds decrypt-only previous master keys, keyring-based fingerprint selection, sequential interop decryption, validation and redaction controls, updated Rust bindings, rotation documentation, and dependency constraints. ChangesEncryption key rotation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CacheHandler
participant EncryptionWrapper
participant PyKeyring
participant ZeroKnowledgeEncryptor
CacheHandler->>EncryptionWrapper: Read encrypted entry
EncryptionWrapper->>PyKeyring: Select fingerprint or try keys sequentially
PyKeyring->>ZeroKnowledgeEncryptor: Decrypt ciphertext
ZeroKnowledgeEncryptor-->>EncryptionWrapper: Return authenticated plaintext
EncryptionWrapper-->>CacheHandler: Return deserialised value
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
This comment has been minimized.
This comment has been minimized.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Panel ran at critical stakes (bug-hunter-supreme, security-specialist, code-craftsman, catchphrase-agent). Surviving findings, all applied: - CRIT (error taxonomy): keyring config violations now raise ValueError, never EncryptionError — EncryptionError is a SerializationError, which handle_decrypt_failure classifies as corruption and fails OPEN even under fail_closed=True; a misconfigured keyring (e.g. programmatic master_key colliding with env previous keys) would have masked itself as 100% misses while evicting readable entries (the LAB-241/LAB-683 config-vs-crypto class). ValueError takes the established fail-loud path, matching the settings-load ValidationError. - CRIT (interop rotation): interop entries carry no per-entry key fingerprint, so fingerprint selection could never use previous keys there — rotation would still have invalidated every interop entry while the docs claimed zero-downtime. Implements the spec's 'Decrypt — without per-entry key identity' row: sequential keyring attempts (current first, identical AAD, exhaustion = plain auth failure into the existing policy) via a new Keyring.decrypt binding + EncryptionWrapper.deserialize_without_key_identity; the single-key interop hot path keeps the cached tenant keys. - MAJ (CWE-532): CachekitConfig sanitizes ValidationError — raw inputs (env-sourced master_key/previous_master_keys hex) no longer appear in str(e), .errors(), .json(), or the exception chain (hide_input_in_errors covers only __str__; __init__ rebuilds the error with inputs redacted and raises outside the except block so __context__ stays None). - MAJ (packaging): pydantic-settings floor 2.0.0 → 2.6.0 (NoDecode). - MIN: setup-time drift guard — keyring fingerprint[0] must equal the cached tenant-keys fingerprint, failing loud at construction instead of silently routing every read to the no-match path on core skew. - MIN: previous_master_keys docstring states the env-merge provenance (explicit master_key still combines with env previous keys by design). - Cut: dead fingerprint delegate on the test keyring spy.
This comment has been minimized.
This comment has been minimized.
Crypto expert-panel review — recorded per the 2026-07-17 project gatePanel ran at critical stakes on cb33221: bug-hunter-supreme, security-specialist, code-craftsman, catchphrase-agent (parallel), findings cross-filtered for pragmatism. Verdict: FIX-FIRST → all surviving findings applied in d4fb28b. Surviving findings → disposition
Rejected findings (with reason)
Cleared explicitly (no action)Binding fingerprint-match with no cross-key fallback; forward-only guard at both layers; no nonce-reuse angle (writes always use current key); FFI exposes only fingerprints + plaintext; attacker-flipped Local gates after fixes: ruff + basedpyright clean, cargo fmt/clippy/test clean, doctest gate green (115), markdown-docs green (121), CI selection 2416 passed. |
pip-audit (Security Fast) went red on the PR: h2 4.3.0, a transitive dep via httpx[http2], accepts duplicate Host headers and forwards both on HTTP/2 -> HTTP/1.1 downgrade — a request smuggling primitive (GHSA-6hr6-w5qg-qmwg, fixed in 4.4.1). Advisory published after main's last Security Fast pass (2026-08-05), so main is equally affected on its next run; this PR just hit it first. Same [tool.uv] constraint-dependencies mechanism as the existing urllib3/fonttools/werkzeug/pip pins. Verified: uv lock resolves h2 4.4.1, local pip-audit reports no known vulnerabilities.
|
@kody start-review |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/features/zero-knowledge-encryption.md`:
- Around line 462-468: Update the key rotation troubleshooting answer in the
zero-knowledge encryption documentation to replace CACHEKIT_MASTER_KEY_ROTATION
with CACHEKIT_PREVIOUS_MASTER_KEYS, and direct readers to follow the keyring
rotation pattern above for retaining the retiring key as decrypt-only.
In `@pyproject.toml`:
- Line 59: Update the pydantic-settings dependency constraint in the project
configuration from >=2.6.0 to >=2.7.0 so NoDecode imports remain available;
leave the existing h2 constraint unchanged.
In `@rust/src/python_bindings.rs`:
- Around line 323-345: Separate authentication failures from configuration or
structural failures in rust/src/python_bindings.rs#L323-L345 by updating decrypt
and decrypt_at to match cachekit-core error kinds, preserving the current
PyValueError for authentication failures and raising a distinct Python exception
for configuration/structural errors. In
src/cachekit/serializers/encryption_wrapper.py#L603-L615, narrow the broad
exception handling to convert only the authentication-failure type into
DecryptionAuthenticationError; allow the distinct configuration/structural
exception to propagate as ValueError.
- Around line 258-283: Update PyKeyring::new to wrap each decrypt_only key in
zeroize::Zeroizing before constructing refs, ensuring the temporary Python-side
allocations are cleared when dropped while preserving the existing Keyring::new
call. Expand the constructor documentation to state the cachekit-core minimum
key length is 16 bytes, and retain the existing Python-side 32-byte validation.
In `@src/cachekit/serializers/encryption_wrapper.py`:
- Around line 245-264: Move the
self._keyring.encryption_fingerprints(self.tenant_id) call out of the try block
in the encryption initialization flow, while leaving tenant-key derivation and
related EncryptionError handling unchanged. Update
test_keyring_config_errors_are_not_serialization_errors to simulate
fingerprint-derivation failure and assert the original ValueError propagates
unchanged rather than being wrapped as EncryptionError.
In `@tests/unit/test_key_rotation_keyring.py`:
- Around line 348-356: Pin CACHEKIT_DEPLOYMENT_UUID explicitly in both rotation
round-trip tests, including test_rotation_round_trip_then_drop and the test at
the additional referenced location. Set it with monkeypatch before constructing
or resetting the CacheSerializationHandler, matching the explicit setup used by
TestInteropRotation._handler, without relying on Path.home() or passing
deployment_uuid.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 2d765649-2e80-4086-bfb7-10bec61bac18
⛔ Files ignored due to path filters (2)
Cargo.lockis excluded by!**/*.lockuv.lockis excluded by!**/*.lock
📒 Files selected for processing (11)
README.mddocs/configuration.mddocs/features/zero-knowledge-encryption.mdpyproject.tomlrust/Cargo.tomlrust/src/lib.rsrust/src/python_bindings.rssrc/cachekit/cache_handler.pysrc/cachekit/config/settings.pysrc/cachekit/serializers/encryption_wrapper.pytests/unit/test_key_rotation_keyring.py
…-684) The keyring decrypt path had no way to distinguish an AES-GCM authentication failure from a configuration or structural one. The binding collapsed every cachekit-core error into PyValueError and the wrapper's broad except converted anything it caught into DecryptionAuthenticationError, which handle_decrypt_failure records as auth_tamper — the event the docs tell operators to alert on as a security incident. A keyring misconfiguration therefore raised a false intrusion alert, and under fail_closed raised to the caller and retained the entry. cachekit-core already keeps the distinction: AuthenticationFailed is the sole tag-verification signal, and KeyringIndexOutOfRange / KeyDerivation / InvalidCiphertext / UnsupportedVersion are caller or operator faults. The binding now maps only the former to the plain ValueError the wrapper treats as tamper, and everything else to KeyringConfigurationError (a ValueError subclass, so it still takes the fail-loud path). Both wrapper decrypt sites are narrowed, not just the sequential one CodeRabbit flagged: decrypt_at shares the binding and therefore shared the defect, and it is the path a rotation-window read actually takes. Also in this round: - Keyring fingerprint derivation moved out of the try block whose handler raises EncryptionError. That handler relabelled a keyring config ValueError as a SerializationError, which the read policy treats as corruption and fails open — silent misses plus entry-by-entry eviction, the LAB-241/LAB-683 class this PR exists to remove. - Zeroize the PyO3-side decrypt_only key vectors; Keyring::new copies them and the originals were freed with key material still in the heap pages. - pydantic-settings floor 2.6.0 -> 2.7.0. NoDecode does not exist in 2.6.x and settings.py imports it at module scope, so the old floor could resolve to a hard ImportError. Verified against the 2.6.0/2.6.1/2.7.0 wheels. - Pin CACHEKIT_DEPLOYMENT_UUID in TestEndToEndRotation: unset, the handler creates ~/.cachekit/deployment_uuid, so the suite wrote to the runner's home and derived keys from filesystem state outside the test. - Troubleshooting doc referenced CACHEKIT_MASTER_KEY_ROTATION, which does not exist; the variable is CACHEKIT_PREVIOUS_MASTER_KEYS.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/cachekit/serializers/encryption_wrapper.py (1)
584-590: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDocument
KeyringConfigurationErrorin theRaisessection.The method deliberately re-raises
KeyringConfigurationErrorat Line 638. TheRaisessection listsValueErroronly for an emptycache_key.KeyringConfigurationErrorsubclassesValueError, so a caller who reads this contract can write a broadexcept ValueErrorfor the empty-key case and then swallow a keyring configuration fault. State the config class explicitly, as the same taxonomy note already does in the code comment.The
deserializedocstring at Lines 397-404 has the same gap.📝 Proposed docstring change
Raises: TypeError: If cache_key is not a string ValueError: If cache_key is empty + KeyringConfigurationError: On a keyring configuration or + ciphertext-structure failure (a ValueError subclass, raised + unconverted so it takes the fail-loud path) DecryptionAuthenticationError: When no keyring entry authenticates the ciphertext EncryptionError: If deserialization fails after authenticated decryption🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cachekit/serializers/encryption_wrapper.py` around lines 584 - 590, Update the Raises sections of both the referenced deserialization method and deserialize to explicitly document KeyringConfigurationError separately from the empty-cache-key ValueError, matching the existing taxonomy comment and preserving the current exception behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/cachekit/serializers/encryption_wrapper.py`:
- Around line 584-590: Update the Raises sections of both the referenced
deserialization method and deserialize to explicitly document
KeyringConfigurationError separately from the empty-cache-key ValueError,
matching the existing taxonomy comment and preserving the current exception
behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 7f5cf064-a258-4f28-a3b5-24f546017523
⛔ Files ignored due to path filters (2)
Cargo.lockis excluded by!**/*.lockuv.lockis excluded by!**/*.lock
📒 Files selected for processing (6)
docs/features/zero-knowledge-encryption.mdpyproject.tomlrust/Cargo.tomlrust/src/python_bindings.rssrc/cachekit/serializers/encryption_wrapper.pytests/unit/test_key_rotation_keyring.py
…ad-path swallow (LAB-684) Expert panel (critical stakes, crypto gate) returned two CRITs against the previous commit. Both were real; both are fixed here. CRIT 1 -- the catch-all arm made the tamper alarm attacker-controlled. Mapping "everything except AuthenticationFailed" to KeyringConfigurationError swept in InvalidCiphertext, which decrypt_aes_gcm returns on a length check that runs BEFORE the AES-GCM tag check. An attacker with backend write access -- the exact threat model this feature exists for -- could truncate a stored entry to under 28 bytes and have the tamper reclassified as a local config fault, choosing whether the alarm fires. The split is now by input provenance: only KeyDerivation and KeyringIndexOutOfRange (inputs we control) are config; everything driven by the stored ciphertext stays tamper-class. This also makes cachekit-py match cachekit-rs, which already mapped exactly those two variants to its Config class, and it dissolves the divergence where decrypt_with_keys and decrypt_at classified identical bytes differently. CRIT 2 -- "subclasses ValueError so it takes the fail-loud path" was false one frame up. The L2 read path dispatches on except SerializationError, and KeyringConfigurationError is a ValueError, so it fell through to the broad except Exception and became a bare return None -- a silent fail-open miss with no metric, no eviction, and no raise even under fail_closed=True. That is the LAB-241/LAB-683 class this PR exists to remove, reintroduced through a new door. All four L2 read sites now re-raise it explicitly. Not routed through handle_decrypt_failure: there is no fail-open/fail-closed decision to make for a misconfigured keyring, it always raises. Verified by reverting cache_handler.py alone -- the new regression test fails with "DID NOT RAISE" and the swallow shows up in the log as "Backend operation failed for get". Also from the panel: - Re-export KeyringConfigurationError from cachekit.serializers. It can escape into application code, so it needs a public name to catch; it was reachable only via the private cachekit._rust_serializer. - Set __module__ to cachekit._rust_serializer. create_exception! leaves the bare module name, so a ProcessPoolExecutor worker raising it would surface ModuleNotFoundError to the parent instead of the real failure. - zeroize is now an optional dep behind the encryption feature, matching pyo3. Both feature combinations verified with cargo check. - Corrected the test that asserted short-ciphertext was config-class; it would have pinned CRIT 1 as intended behaviour. Dropped the unreachable decrypt() stub on the test fake -- deserialize only reaches decrypt_at.
This comment has been minimized.
This comment has been minimized.
Review round addressed — 6/6 CodeRabbit items, with one deliberate deviationCommits Applied as written (5)
Deviation —
|
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== changed files =='
git diff --name-only "$(git merge-base HEAD origin/main 2>/dev/null || true)" HEAD 2>/dev/null || git diff --name-only HEAD~1 HEAD
printf '%s\n' '== source outlines =='
ast-grep outline rust/src/python_bindings.rs --items all --match 'Keyring|decrypt|Error' || true
ast-grep outline src/cachekit/serializers/encryption_wrapper.py --items all --match 'EncryptionWrapper|deserialize|Keyring' || true
ast-grep outline src/cachekit/cache_handler.py --items all --match 'get|deserialize|Keyring' || true
ast-grep outline src/cachekit/config/settings.py --items all --match 'CachekitConfig|previous_master' || true
printf '%s\n' '== all configuration-error references and handler contexts =='
rg -n -C 5 '\bKeyringConfigurationError\b' rust/src/python_bindings.rs src tests || true
printf '%s\n' '== encryption and deserialization error handler contexts =='
rg -n -C 8 'except (SerializationError|DecryptionAuthenticationError|KeyringConfigurationError|Exception)' src/cachekit/cache_handler.py src/cachekit/serializers/encryption_wrapper.py || true
printf '%s\n' '== dependency constraints =='
rg -n -C 3 'pydantic-settings|h2' pyproject.toml
printf '%s\n' '== exports and binding registration =='
rg -n -C 4 'KeyringConfigurationError|PyKeyring|add_class|add\(' rust/src/python_bindings.rs src/cachekit/serializers || true
printf '%s\n' '== rotation test symbols =='
rg -n -C 3 'fingerprint_derivation_failure|TestEndToEndRotation|DEPLOYMENT_UUID|KeyringConfigurationError|InvalidCiphertext' tests/unit/test_key_rotation_keyring.py || trueLength of output: 94698 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== relevant commit diffs =='
git diff --unified=25 b46513c^ bc7a91f -- \
pyproject.toml rust/Cargo.toml rust/src/python_bindings.rs \
src/cachekit/cache_handler.py src/cachekit/serializers/encryption_wrapper.py \
src/cachekit/serializers/__init__.py tests/unit/test_key_rotation_keyring.py \
docs/features/zero-knowledge-encryption.md || true
printf '%s\n' '== Rust binding implementation sections =='
sed -n '90,165p' rust/src/python_bindings.rs
sed -n '300,405p' rust/src/python_bindings.rs
sed -n '475,505p' rust/src/python_bindings.rs
printf '%s\n' '== wrapper setup and both decrypt methods =='
sed -n '215,285p' src/cachekit/serializers/encryption_wrapper.py
sed -n '485,655p' src/cachekit/serializers/encryption_wrapper.py
printf '%s\n' '== settings key parsing/redaction =='
sed -n '1,225p' src/cachekit/config/settings.py
printf '%s\n' '== every CacheOperationHandler deserialization invocation =='
rg -n -C 7 '\b(?:self\.serialization_handler|serialization_handler)\.deserialize_data\s*\(' src/cachekit/cache_handler.py
printf '%s\n' '== all cache-handler methods and tests for L2 configuration propagation =='
ast-grep outline src/cachekit/cache_handler.py --items all --match 'get_cached_value|deserialize_data'
rg -n -C 5 'get_cached_value_with_freshness|get_cached_value_async|freshness_async|KeyringConfigurationError' tests
printf '%s\n' '== taxonomy comments mentioning structural/ciphertext failures =='
rg -n -C 3 'ciphertext-structure|short ciphertext|InvalidCiphertext|config.*tamper|tamper.*config' \
rust/src/python_bindings.rs src/cachekit/serializers/encryption_wrapper.py tests/unit/test_key_rotation_keyring.pyLength of output: 124006
The deliberate error-taxonomy deviation is correct. The four L2 read methods re-raise One non-blocking correction remains:
No functional security issue remains from the reviewed changes. 🐇
|
…too (LAB-684) The previous commit closed the swallow at the four L2 read sites but left the two L1 sites in decorators/wrapper.py, which have the identical shape: an except SerializationError guard followed by a catch-all that logs 'L1 cache deserialization failed', invalidates the entry, and falls through. For a local keyring config fault none of that is true — the L1 entry is fine, the message misattributes the failure, and the invalidate is gratuitous. Both Kody and the expert panel flagged L1 separately; fixing only the sites the first report named would have left the same defect in the sibling callers.
This comment has been minimized.
This comment has been minimized.
Kody round triaged — 1 real finding fixed, 6 rejected as rule misfiresCommit Accepted — the L1 read sites (medium, Bug)You were right, and I had only half-fixed it. For a local keyring config fault none of that is true: the L1 entry is fine, the message misattributes the failure, and the invalidate is gratuitous. Both L1 sites now re-raise, matching L2. Worth noting the two duplicate threads on Rejected (6)
Gate status2184 passed / 22 skipped ( @kody start-review |
…aise (LAB-684) Kody caught an asymmetry the panel missed: the new sync L1 guard re-raised without reset_current_function_stats(token), while its DecryptionAuthenticationError sibling nine lines up does reset. The sync wrapper has no outer finally, so that exit leaked the token. The async guard is correct as written — its wrapper's outer finally covers every exit path.
Kody Review CompleteGreat news! 🎉 Keep up the excellent work! 🚀 Kody Guide: Usage and ConfigurationInteracting with Kody
Current Kody ConfigurationReview OptionsThe following review options are enabled or disabled:
|
Closes LAB-684 (LAB-516 stage 2). Design: protocol
decisions/key-rotation.md+spec/encryption.md→ "Key Rotation (Keyring)" (protocol #34). Builds on the cachekit-core 0.5.0Keyringhelper (LAB-683, cachekit-core #67).What
An operator can now rotate the master key without invalidating every encrypted entry: up to 3 decrypt-only previous master keys via
CACHEKIT_PREVIOUS_MASTER_KEYS(comma-separated hex), with per-entry selection by exact derived-key fingerprint match — cachekit-py stores a per-entrykey_fingerprintin CK frame metadata, so per spec L368 it must never trial-decrypt across the keyring.config/settings.py):previous_master_keys: list[SecretStr]. Load-time rejection (never truncation) of: >3 entries; non-hex / <32-byte keys (per-key validation identical tomaster_key);master_keyre-appearing in the list (detectable subset of the spec's forward-only invariant — re-promotion resumes a used AES-GCM nonce budget). Redacted inrepr/str/get_safe_repr.serializers/encryption_wrapper.py): the frame'skey_fingerprintis matched against each keyring entry's HKDF-derived per-tenant encryption-key fingerprint (never the master-key fingerprint), current key first. A match is binding: the matched entry is the only key used; its AES-GCM failure is terminal (straight to fail_closed/fail_open, no further entries). No match → pre-keyring mismatch semantics unchanged — all existing fingerprint-mismatch tests pass unmodified. Current-key reads keep the cached derived tenant keys (no per-read HKDF on the hot path).rust/src/python_bindings.rs): newKeyringbinding (constructor,encryption_fingerprints,decrypt_at). Key material enters once at config ingestion and never crosses back into Python; keyring material zeroizes on drop in Rust (cachekit-coreZeroizeOnDrop).PyKeyRotationStatebinding at all three sites (pyclass block, import,add_classregistration) — the LAB-275 trust bug, zero Python callers since inception. The 0.5.0 bump makes the removal compiler-enforced (key_rotationno longer exists in core). A test assertsKeyRotationStateis no longer importable.docs/configuration.md, anddocs/features/zero-knowledge-encryption.mdrotation sections rewritten to the real surface — including deleting the never-implementedCACHEKIT_MASTER_KEY_ROTATIONenv var the docs had invented. New doctests (executed in CI) cover the rotation round-trip and forward-only rejection; nonotest/+SKIPadded anywhere.Tests
27 new tests in
tests/unit/test_key_rotation_keyring.py: env parsing, cap/subset/per-key load rejection, redaction; spy-instrumented proofs that selection touches exactly one keyring entry (no trial decryption), that a binding match is terminal (remaining entries untried, no retreat to the current key), and that fail-open no-match attempts the current key only; end-to-end rotation round-trip throughCacheSerializationHandlerand the@cachedecorator (k₁ write → k₂+[k₁] read without re-encryption or recompute → k₁ dropped → fail policy honored); FFI-hygiene assertions.Local gates: ruff check/format clean, basedpyright 0 errors,
cargo fmt/clippy/testclean, full doctest gate green, CI test selection green (2411 passed; only the pre-existing live-SaaS e2e suite fails without an API key, identical onmain).Out of scope (LAB-687, stage 3)
Rotation runbook docs page, protocol feature-matrix flip, conformance vectors. Sibling PRs: LAB-685 (ts), LAB-686 (rs).
Supply-chain evidence (dependency changes)
cachekit-core0.4.0 → 0.5.0 (rust/Cargo.toml): first-party crate, published by this org from cachekit-core#67 + cachekit-core#68 (release cachekit-core-v0.5.0). OSV query forcachekit-core 0.5.0(crates.io) returns zero known vulnerabilities (api.osv.dev/v1/query, 2026-08-08).Cargo.lockis updated in this diff and pins the exact version with its crates.io registry checksum (12089baa…); CI's Vulnerability Scan and License & Supply Chain checks are green on this PR.h2constrained to ≥4.4.1 (pyproject.toml[tool.uv] constraint-dependencies+uv.lock): fixes GHSA-6hr6-w5qg-qmwg (duplicate-Host request smuggling primitive) flagged by the PR's pip-audit gate; advisory published aftermain's last Security Fast pass, somainis equally affected on its next run. Localpip-audit: no known vulnerabilities. OSV:h2 4.4.1(PyPI) has zero known advisories.Summary by CodeRabbit
New Features
Documentation