From badab462cd1a0bea10155cb4f6d7a434b9d58334 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 2 Sep 2026 21:03:12 +1000 Subject: [PATCH 1/3] docs: link key rotation runbook (LAB-687) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f6fae3..425611e 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ def get_patient_data(hospital_id: int): retiring key readable via `CACHEKIT_PREVIOUS_MASTER_KEYS` (comma-separated hex, max 3 decrypt-only keys). Entries are selected by exact key fingerprint — never trial decryption — and old entries age out via TTL, no cache flush required. See -[Zero-Knowledge Encryption](docs/features/zero-knowledge-encryption.md#key-rotation-pattern). +[key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/). cachekit employs comprehensive security tooling: From 1c5670589c1beee89cbbc6668f15d436251edd7c Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 3 Sep 2026 09:49:45 +1000 Subject: [PATCH 2/3] docs: clarify key rotation rollout (LAB-687) --- README.md | 12 +++++--- docs/features/zero-knowledge-encryption.md | 36 ++++++++++++---------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 425611e..29b83b7 100644 --- a/README.md +++ b/README.md @@ -290,11 +290,13 @@ def get_patient_data(hospital_id: int): > [!CAUTION] > When handling PII, medical, or financial data, always use `@cache.secure` to enforce encryption. -**Zero-downtime key rotation**: promote a new `CACHEKIT_MASTER_KEY` and keep the -retiring key readable via `CACHEKIT_PREVIOUS_MASTER_KEYS` (comma-separated hex, -max 3 decrypt-only keys). Entries are selected by exact key fingerprint — never -trial decryption — and old entries age out via TTL, no cache flush required. See -[key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/). +**Key rotation**: keep a retiring key readable with +`CACHEKIT_PREVIOUS_MASTER_KEYS` (comma-separated hex, max 3 decrypt-only keys) +while new writes use `CACHEKIT_MASTER_KEY`. Scheduled rotation avoids +rotation-caused cache misses only when it follows the runbook's three phases; +a one-deploy key swap is not zero-miss. Entries are selected by exact key +fingerprint — never trial decryption. See the [key rotation +runbook](https://docs.cachekit.io/concepts/key-rotation/). cachekit employs comprehensive security tooling: diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index d350aaf..6e15a95 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -143,18 +143,20 @@ export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32) ### Key Rotation +Keeping a retiring key decrypt-only makes its entries readable; it does **not** +make a one-deploy key swap zero-miss. For a scheduled rotation, follow the +[three-phase key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/): +deploy the incoming key decrypt-only to every reader first, promote it only +after that rollout completes, then retire the old key after the longest TTL. +The configuration below is the phase-2 state, not a standalone rotation recipe. + ```bash -# Changed CACHEKIT_MASTER_KEY without retaining the old key -# Old encrypted data in Redis → Can't decrypt -# Error: "Decryption failed: authentication tag verification failed" -# Solution: keep the retiring key decrypt-only for the rotation window +# Phase 2 only: the new key is current after the phase-1 fleet rollout. export CACHEKIT_MASTER_KEY=new_key # encrypts + decrypts export CACHEKIT_PREVIOUS_MASTER_KEYS=old_key # decrypt-only (comma-separated, max 3) -# Restart app → old entries stay readable, new writes use the new key. -# Old-key entries age out via TTL; drop the old key from the list once the -# window (≥ longest TTL in use) has passed. Rotation is forward-only: never -# re-promote a retired key to CACHEKIT_MASTER_KEY — a configuration where the -# current key also appears in the previous-keys list is rejected at load. +# After the longest TTL has elapsed from fleet-wide promotion: +# unset CACHEKIT_PREVIOUS_MASTER_KEYS +# Never re-promote a retired key; rotate forward to a fresh key instead. ``` ### Enabling Encryption on an Existing (Plaintext) Cache @@ -286,21 +288,23 @@ data_b = get_user_data(123) # Same user_id, different tenant, different encrypt ### Key Rotation Pattern -Zero-downtime rotation via the keyring: one **current** master key +The keyring has one **current** master key (`CACHEKIT_MASTER_KEY`, encrypts and decrypts) plus up to **3 decrypt-only** previous keys (`CACHEKIT_PREVIOUS_MASTER_KEYS`, comma-separated hex, same per-key requirements as the master key). Entries carry the fingerprint of their HKDF-derived per-tenant encryption key, so reads select the exact -keyring entry that wrote them — never trial decryption. +keyring entry that wrote them — never trial decryption. The keyring alone does +not make a single-deploy swap zero-miss: use the [three-phase key rotation +runbook](https://docs.cachekit.io/concepts/key-rotation/) for scheduled +rotation. ```bash -# 1. Promote the new key; retain the old key decrypt-only +# Phase 2 only, after phase 1 deployed the incoming key decrypt-only fleet-wide. export CACHEKIT_MASTER_KEY= export CACHEKIT_PREVIOUS_MASTER_KEYS= -# 2. Old entries still decrypt (selected by key fingerprint); new writes use the new key -# 3. Old-key entries age out via TTL (or re-encrypt on the next write) -# 4. After the window (≥ longest TTL in use), drop the old key -unset CACHEKIT_PREVIOUS_MASTER_KEYS +# Old entries still decrypt; new writes use the new key. +# After the longest TTL from fleet-wide promotion: +# unset CACHEKIT_PREVIOUS_MASTER_KEYS ``` Rules enforced at config load — rejected, never truncated or silently fixed: From 6f103fab91cefe7e375c25c5622520cea9f48ffa Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 3 Sep 2026 10:17:35 +1000 Subject: [PATCH 3/3] docs: scope key-selection guarantee to CK-framed entries; mark key examples as hex placeholders (LAB-687) Address CodeRabbit review on #277: - Key-rotation env examples used non-hex placeholders (new_key/old_key) that fail config validation; mark blocks as pseudocode requiring 64-char hex. - Key-selection guarantee omitted the Interop-mode exception; scope the fingerprint-selection claim to CK-framed entries and note Interop-mode attempts keyring keys sequentially (doc + README). --- README.md | 5 +++-- docs/features/zero-knowledge-encryption.md | 16 +++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 29b83b7..251ac48 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,9 @@ def get_patient_data(hospital_id: int): `CACHEKIT_PREVIOUS_MASTER_KEYS` (comma-separated hex, max 3 decrypt-only keys) while new writes use `CACHEKIT_MASTER_KEY`. Scheduled rotation avoids rotation-caused cache misses only when it follows the runbook's three phases; -a one-deploy key swap is not zero-miss. Entries are selected by exact key -fingerprint — never trial decryption. See the [key rotation +a one-deploy key swap is not zero-miss. CK-framed entries are selected by exact +key fingerprint — never trial decryption; Interop-mode entries carry no CK frame +and attempt keyring keys sequentially instead. See the [key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/). cachekit employs comprehensive security tooling: diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 6e15a95..4eda869 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -152,8 +152,10 @@ The configuration below is the phase-2 state, not a standalone rotation recipe. ```bash # Phase 2 only: the new key is current after the phase-1 fleet rollout. -export CACHEKIT_MASTER_KEY=new_key # encrypts + decrypts -export CACHEKIT_PREVIOUS_MASTER_KEYS=old_key # decrypt-only (comma-separated, max 3) +# Pseudocode — replace the placeholders with 64-character hex (32-byte) values, +# e.g. `$(openssl rand -hex 32)`. Non-hex or short values are rejected at load. +export CACHEKIT_MASTER_KEY= # encrypts + decrypts +export CACHEKIT_PREVIOUS_MASTER_KEYS= # decrypt-only (comma-separated, max 3) # After the longest TTL has elapsed from fleet-wide promotion: # unset CACHEKIT_PREVIOUS_MASTER_KEYS # Never re-promote a retired key; rotate forward to a fresh key instead. @@ -291,15 +293,19 @@ data_b = get_user_data(123) # Same user_id, different tenant, different encrypt The keyring has one **current** master key (`CACHEKIT_MASTER_KEY`, encrypts and decrypts) plus up to **3 decrypt-only** previous keys (`CACHEKIT_PREVIOUS_MASTER_KEYS`, comma-separated hex, same -per-key requirements as the master key). Entries carry the fingerprint of -their HKDF-derived per-tenant encryption key, so reads select the exact -keyring entry that wrote them — never trial decryption. The keyring alone does +per-key requirements as the master key). CK-framed entries carry the +fingerprint of their HKDF-derived per-tenant encryption key, so reads select +the exact keyring entry that wrote them — never trial decryption. (Interop-mode +entries carry no CK frame and instead attempt keyring keys sequentially — see +the Interop-mode note below.) The keyring alone does not make a single-deploy swap zero-miss: use the [three-phase key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/) for scheduled rotation. ```bash # Phase 2 only, after phase 1 deployed the incoming key decrypt-only fleet-wide. +# Pseudocode — replace the placeholders with 64-character hex (32-byte) values, +# e.g. `$(openssl rand -hex 32)`. Non-hex or short values are rejected at load. export CACHEKIT_MASTER_KEY= export CACHEKIT_PREVIOUS_MASTER_KEYS= # Old entries still decrypt; new writes use the new key.