A Kafka topic accumulates partially encrypted search query log records over time. This demo shows that when the encryption key is rotated, every record in the topic, regardless of which key encrypted it, continues to decrypt correctly at the proxy. Key rotation can be achieved either by choosing a different key id within the same keyset ("intra-keyset rotation") or by switching over to an entirely new keyset ("cross-keyset rotation").
The stack is intentionally minimal to focuse on the key rotation aspect. There is no schema registry in use. The sample records are plain JSON, hence, the filter is configured with record_format: JSON.
| Container | Image | Role |
|---|---|---|
kafka |
quay.io/strimzi/kafka:0.51.0-kafka-4.2.0 |
KRaft-mode single-node Kafka broker |
kroxylicious |
hpgrahsl/kroxylicious-kryptonite:0.23.0-0.1.0 |
Kroxylicious proxy (0.23.0) with Kryptonite for Kafka filter (0.1.0) |
flowchart LR
P([producer\nvia proxy]) -->|plaintext JSON| KE
subgraph kroxylicious [kroxylicious:9192]
KE["<strong>encrypt filter</strong><br/><br/>ip_address<br/>query<br/><br/><em>with keys rotated twice, intra-keyset and cross-keyset</em>"]
KD["<strong>decrypt filter</strong><br/><br/>ip_address<br/>query"]
end
KE -->|ciphertext JSON| K[(kafka:9092)]
K -->|ciphertext JSON| KD
KD -->|plaintext JSON| CP([consumer\nvia proxy])
K -->|ciphertext JSON| CD([consumer\ndirect])
The demo runs in three sequential phases. Each phase ingests 100 search query log records into the same topic (demo-kroxy-k4k-rotation), always encrypting the same two fields (ip_address, query). Only the active default keyset and its designated key id change between phases.
| Phase | Config file | Active keyset | Primary key ID | Records produced |
|---|---|---|---|---|
| 1 | proxy_config_phase1.yaml |
keyset_1 |
10000 |
100 |
| 2 | proxy_config_phase2.yaml |
keyset_1 |
10001 |
100 |
| 3 | proxy_config_phase3.yaml |
keyset_2 |
10003 |
100 |
After all three phases the topic holds 300 records encrypted under three different keys. A single consume_proxy.sh call decrypts all 300 correctly.
The proxy configuration for each phase is in the corresponding file:
proxy_config.yaml (what Docker Compose mounts) starts as a copy of proxy_config_phase1.yaml. You replace its content with the next phase config and restart Kroxylicious to advance to the next phase of the demo.
Kroxylicious exposes a virtual cluster (demo-cluster) that forwards all traffic to the real broker at kafka:9092. Clients connect to kroxylicious:9192.
defaultFilters:
- k4k-encrypt
- k4k-decryptBoth filters are active on all traffic. The encrypt filter runs on the produce path; the decrypt filter runs on the fetch path.
Both filters use record_format: JSON. No schema registry is required.
Both keysets, keyset_1 (key IDs 10000, 10001, 10002) and keyset_2 (key IDs 10003, 10004, 10005), are present in every phase config. What changes across phases is which keyset is the active default (cipher_data_key_identifier) and which key within that keyset is the primary (primaryKeyId).
| Phase | Active keyset | Primary key ID | Keysets available for decryption |
|---|---|---|---|
| 1 | keyset_1 |
10000 |
keyset_1 (10000, 10001, 10002), keyset_2 (10003, 10004, 10005) |
| 2 | keyset_1 |
10001 |
keyset_1 (10000, 10001, 10002), keyset_2 (10003, 10004, 10005) |
| 3 | keyset_2 |
10003 |
keyset_1 (10000, 10001, 10002), keyset_2 (10003, 10004, 10005) |
The filter applies to all topic names matching demo-kroxy-k4k-rotation*:
topic_field_configs:
- topic_pattern: demo-kroxy-k4k-rotation*
field_configs:
- name: ip_address
- name: queryBoth fields use the default keyset and algorithm (cipher_data_key_identifier / cipher_algorithm) in each phase. No per-field key overrides are exercised in this example, although it would work in the same way.
Every encrypted field based on non-FPE ciphers is effectively self-contained, which allows:
-
to derive the specific key id within a Tink keyset used during encryption
- intra-keyset rotation (shown in demo phase 2): promoting key id
10001to primary withinkeyset_1means new records are encrypted with key id10001. Old records encrypted with key id10000still decrypt correctly so long key id10000remains in the keyset.
- intra-keyset rotation (shown in demo phase 2): promoting key id
-
to infer the keyset originally used during encryption
- Cross-keyset rotation (shown in demo phase 3): switching the default to
keyset_2means new records carrykeyset_2in the meta data of each encrypted field. Old records carrykeyset_1and still allow the decrypt filter to correctly decrypt the data givenkeyset_1is still accessible at that time.
- Cross-keyset rotation (shown in demo phase 3): switching the default to
{
"log_id": "P1-Q0001",
"timestamp": "2025-01-15T06:00:37Z",
"ip_address": "203.0.113.8",
"query": "tink cryptography library",
"num_results": 125,
"response_time_ms": 19,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}{
"log_id": "P1-Q0001",
"timestamp": "2025-01-15T06:00:37Z",
"ip_address": "azIwMDAyCGtleXNldF8xAQAAJxB1okTBufgJQ4SrQdXVmhyu0pwSbHMsh68xjc9/gS6gOh7bKyiT6EAF",
"query": "azIwMDAyCGtleXNldF8xAQAAJxDXTZh2XSuyJOlPKZGU0KmFYb7r07NJem99/t10/oGPaRXODOXVsPa3u5yQVt3tDxua1xvqN5U=",
"num_results": 125,
"response_time_ms": 19,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}{
"log_id": "P1-Q0001",
"timestamp": "2025-01-15T06:00:37Z",
"ip_address": "203.0.113.8",
"query": "tink cryptography library",
"num_results": 125,
"response_time_ms": 19,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}Run all the following commands from within the ./scenario_05/ folder.
Before starting the stack, copy the proxy configuration for phase 1 as the currently active config:
cp proxy_config_phase1.yaml proxy_config.yamlThis configuration instructs the proxy filter to work with:
TINK/AES_GCMas the default cipher algorithmkeyset_1as the default keyset using key id10000(current primary key)
for encrypting the two fields ip_address and query.
docker compose up -dProduce the first batch of 100 query log JSON records:
docker exec kafka /home/kafka/scripts/produce_proxy.sh /home/kafka/data/querylogs_phase1.jsonlThe proxy filter encrypts them with keyset_1 and key id 10000 according to the currently active configuration for phase 1 ingestion.
Consume the records directly from the Kafka topic to see the partially encrypted records:
docker exec kafka /home/kafka/scripts/consume_direct.sh 100The ip_address and query fields are ciphertext in every record. All other fields (log_id, timestamp, num_results, response_time_ms, user_agent) are untouched.
Before ingesting more data, you reconfigure the proxy filter to rotate the encryption key by choosing a different key id from within the same keyset.
Make the phase 2 config the currently active one and restart Kroxylicious.
cp proxy_config_phase2.yaml proxy_config.yaml
docker compose restart kroxyliciousThis configuration instructs the proxy filter to now use the keyId 10001 from keyset_1.
With this config change in place, produce the next batch of 100 records:
docker exec kafka /home/kafka/scripts/produce_proxy.sh /home/kafka/data/querylogs_phase2.jsonldocker exec kafka /home/kafka/scripts/consume_proxy.sh 200All 200 records produced so far are supposed to decrypt successfully and show the original plaintext for the ip_address and query fields, regardless of which key id (10000 or 10001) from keyset_1 has been used to encrypt them.
Before ingesting more data, you reconfigure the proxy filter once again to rotate the encryption key by choosing a different keyset as the new default keyset to use.
Make the phase 3 config the currently active one and restart Kroxylicious.
cp proxy_config_phase3.yaml proxy_config.yaml
docker compose restart kroxyliciousThis configuration instructs the proxy filter to now use keyset_2 and key id 10003.
With this config change in place, produce the final batch of 100 records:
docker exec kafka /home/kafka/scripts/produce_proxy.sh /home/kafka/data/querylogs_phase3.jsonlRun a consumer against the proxy to verify the decryption across the different keysets and key ids in use:
docker exec kafka /home/kafka/scripts/consume_proxy.sh 300All 300 records across ingest phases 1 to 3:
100based onkeyset_1and key id10000(initial PK)100based onkeyset_1and key id10001(promoted PK)100based onkeyset_2and key id10003(inital PK)
are supposed to be decrypted correctly by the proxy filter, irrespective the keyset / key id used.
Restore the inital configuration by running:
cp proxy_config_phase1.yaml proxy_config.yamldocker compose down