Architecture Overview
Kloak is a Kubernetes-native secret protection system that uses eBPF to rewrite secret placeholders with real values at the kernel level, after TLS encryption. Applications never see actual secrets -- they work with harmless kloak: placeholders that are transparently substituted in the encrypted output.
Components
Kloak consists of three main components deployed in the kloak-system namespace:
Controller (DaemonSet)
The controller runs as a DaemonSet -- one pod per node -- because eBPF programs must be loaded on the same kernel where the target processes run.
It performs four functions:
SecretReconciler -- Watches Kubernetes Secrets labeled
getkloak.io/enabled=true. For each enabled secret, creates a shadow secret (<name>-kloak, owned by the original and labeledgetkloak.io/managed=true) whose values arekloak:placeholders:kloak:followed by random uppercase letters and digits (from a ULID, random part first), cut or padded to exactly the real value's byte length, with an HPACK Huffman encoding at least as long as the real value's. There is no separate in-memory store: every 5 seconds the controller joins each enabled Secret with its shadow from its informer cache, reads thegetkloak.io/hosts/getkloak.io/portlabels, and syncs placeholder-prefix -> real-value entries into thesecret_mapBPF map.Pod Reconciler -- Watches Pods annotated
getkloak.io/enabled=trueon the local node. When a matching pod is detected, resolves each container's cgroup ID, then delegates to the TLS Uprobe Manager. Failed attachments are retried every 500ms (handles runtimes like Python wherelibsslloads lazily).TLS Uprobe Manager -- Loads eBPF programs into the kernel, attaches uprobes to each container process's TLS write functions, attaches the tc patch program at tc egress on the pod's
eth0andlointerfaces (TCX, inside the pod's network namespace), and syncs the secret map to BPF every 5 seconds. Also tracks process lifecycle (exec/exit) in tracked cgroups to attach uprobes to newly spawned processes.Trusted DNS Discovery -- Auto-discovers the
kube-dnsClusterIP from thekube-system/kube-dnsservice and populates thetrusted_dns_serversBPF map. Only DNS responses from trusted servers are used for host filtering. Additional servers can be configured viacontroller.dns.trustedServers(the--trusted-dns-serversflag).
Webhook (Deployment)
The webhook runs as a standard Deployment (typically 1 replica). It serves a mutating admission webhook for pod CREATE and a validating admission webhook for Secrets.
One MutatingWebhookConfiguration (kloak-mutating-webhook) contains two webhooks that ensure only kloak-enabled workloads are sent to the webhook (both use failurePolicy: Fail and never match the Kloak release namespace):
- Namespace-scoped: matches namespaces labeled
getkloak.io/enabled=true - Pod-scoped: matches pods labeled
getkloak.io/enabled=trueviaobjectSelector
Non-kloak workloads are never affected, even if the webhook is down.
When a pod is matched:
- Checks if Kloak is enabled for this pod: if the pod has the
getkloak.io/enabledlabel, its value decides ("false"opts out even in an enabled namespace); otherwise the namespace label decides - Scans the pod's
volumes[].secret. Environment variable references (secretKeyRef,envFrom) and projected volume sources are not rewritten, so a pod that uses them gets the real value. - For each volume of a kloak-enabled secret, rewrites the name from
originaltooriginal-kloak - Rejects the pod if any kloak-enabled secret's shadow does not exist yet or is not Kloak-managed (fail-closed)
- Adds
getkloak.io/enabled: "true"annotation to the pod so the controller knows to attach eBPF uprobes
A ValidatingWebhookConfiguration (kloak-validating-webhook, failurePolicy: Fail) checks CREATE/UPDATE of Secrets labeled getkloak.io/enabled=true. It rejects a getkloak.io/hosts label that is not a lowercase DNS name, an invalid getkloak.io/port label, Secrets with no data, and values shorter than 8 or longer than 128 bytes. It does not check annotations: getkloak.io/hosts or getkloak.io/port set as annotations are ignored.
eBPF Programs
The eBPF programs run in-kernel and are loaded by the controller. The secret rewriting pipeline has three stages:
Stage 1: Uprobe -- Scan and Compute
When SSL_write or crypto/tls.(*Conn).Write is called, the uprobe fires and:
- Reads the plaintext write buffer (up to 256 bytes per chunk, scanning the full buffer via
bpf_loop) - Resolves the destination hostname via the DNS trust chain (see below)
- Pre-scans for the
kloak:prefix (or its HPACK Huffman encoding, for HTTP/2), finding up to 4 matches per call - For each match, uses the 8 bytes at the match as the key to look up the real secret value in the
secret_mapBPF hash map - Checks the resolved hostname and the destination port against the secret's allowed host and port
- Computes XOR deltas:
xor_delta[i] = shadow_byte[i] ^ real_byte[i]for each matched secret - Stores the pending patches in
xor_pending(keyed by process ID / tgid). The plaintext buffer is not modified.
The GHASH key H is obtained per connection. For OpenSSL, the tcp_sendmsg kprobe walks the SSL struct to the GCM context at version-specific offsets (3-hop chain for OpenSSL 3.0/3.1, 4-hop for 3.2+). For Go crypto/tls, the kprobe reads H*2 from the GCM productTable and applies GF(2^128) halving. BoringSSL keeps no H that Kloak can read, so BoringSSL connections are not rewritten.
Stage 2: Kprobe -- Bridge to Network
A kprobe on tcp_sendmsg fires when the TLS library sends the encrypted data:
- Reads the pending patches from
xor_pending - Extracts the source port and destination IP from the socket (IPv4 destinations only)
- Obtains H if the uprobe did not (Go, or the OpenSSL fallback walk). If H cannot be obtained, no patch is created and the placeholder is sent.
- Builds a
tc_pendingentry keyed by(destination IP, source port, cgroup ID), recording the TCP write sequence number to identify the right segment - The patches are now ready for the tc program
Stage 3: TC -- Patch Ciphertext
A tc (traffic control) program attached at tc egress on the pod's eth0 and lo interfaces, inside the pod's network namespace, as TCX links (Linux 6.6+), intercepts the pod's outbound packets. On older kernels the attach fails and nothing is rewritten. Because the patch happens inside the pod's network namespace, a container in the pod with CAP_NET_RAW could capture the patched ciphertext -- drop NET_RAW for Kloak-enabled workloads. Loopback is patched too, so a TLS server in the same pod can receive the real value of a secret without a host filter.
- Looks up
tc_pendingby matching the packet's destination IP, source port, and cgroup - For each patch, XORs the corresponding ciphertext bytes:
CT_real = CT_shadow XOR xor_delta - Recomputes the GHASH authentication tag using precomputed H powers (GF(2^128) multiplication)
- Patches the authentication tag in the TLS record
- The packet leaves the node with the real secret encrypted -- the pod's memory never held the real value
Why XOR patching works
AES-GCM in counter mode (CTR) encrypts via ciphertext = plaintext XOR keystream. If you know the XOR difference between the shadow and real values, you can patch the ciphertext directly: CT_real = CT_shadow XOR (shadow XOR real). The keystream cancels out. The GHASH tag must be recomputed because the ciphertext changed.
Only AES-128-GCM and AES-256-GCM cipher suites (TLS 1.2 and 1.3) are supported. ChaCha20-Poly1305 connections are not rewritten and send the placeholder.
Go crypto/tls
Go crypto/tls uses the same XOR-patch pipeline. There is no plaintext fallback: if H cannot be obtained, the placeholder is sent.
DNS-Verified Host Resolution
Additional eBPF programs build a chain of trust from DNS resolution to TLS write:
- DNS Kprobe (
udp_recvmsg) -- Intercepts DNS responses on the node. Validates the source against thetrusted_dns_serverswhitelist. For hostnames in thewatched_hostsset, stores resolved A/AAAA records indns_ip_mapwith TTL. - Connect Tracepoints (
sys_enter/exit_connect) -- Tracks TCP connections (fd to destination IP) inconn_ip_map. When the destination IP exists indns_ip_map, caches the fd inlast_verified_fdfor fast lookup. - Close Tracepoint (
sys_enter_close) -- Cleans upconn_ip_mapentries when file descriptors are closed, preventing stale mappings after fd reuse.
At TLS write time, resolve_host() finds the socket fd via ssl_fd_map (cache) -> the fd read from the SSL struct's write BIO -> last_verified_fd, then chains conn_ip_map[{tgid, fd}] -> dns_ip_map[ip] to determine the hostname. If no fd is found, it scans fds 3-30 in conn_ip_map for a DNS-verified destination.
Admission Flow
When a secret and pod are created, the controller and webhook set up the shadow secret and mutate the pod:
Secret Rewrite Flow
When the application makes a TLS call, the eBPF pipeline rewrites the secret in the encrypted output:
Security Model
Real secret values never enter application memory. The application only sees kloak: placeholders. Real values exist in the original Secret (API server/etcd), in the controller's informer cache (which holds all Secrets cluster-wide), and in kernel-space BPF maps -- all inaccessible to application containers. The XOR-patch pipeline injects secrets into the ciphertext after TLS encryption, so they never pass through the application's memory. The patched packets do pass through the pod's network namespace (see above).
For the full threat model, trust chain, fail modes, and known limitations, see the Security Model page.
BPF Map Layout
Core Maps
| Map | Type | Key | Value | Purpose |
|---|---|---|---|---|
secret_map | Hash | First 8 bytes of the placeholder (kloak:xx) or of its HPACK Huffman encoding | Real value (128B) + host (64B) + port + protocol + full prefix (42B) | Placeholder-to-secret lookup (max 4096 entries; each secret uses up to 2) |
tls_conn_state | LRU Hash | GHASH H (16B) + H powers (16x16B) + cipher type | Per-connection TLS state for GHASH recomputation | |
xor_pending | Hash | tgid | Patches (up to 4) with offset, length, XOR delta | Uprobe to kprobe bridge: pending ciphertext patches |
tc_pending | LRU Hash | Patches + TCP write sequence | Kprobe to tc bridge: patches for a specific connection | |
tc_tag_pending | LRU Hash | GHASH tag delta | Deferred tag fix-up when the tag is in a later TCP segment |
DNS and Connection Tracking
| Map | Type | Key | Value | Purpose |
|---|---|---|---|---|
dns_ip_map | LRU Hash | IP address (16B) | Hostname (64B) + TTL + timestamp | DNS-verified IP-to-hostname cache |
conn_ip_map | LRU Hash | IP address (16B) + port | TCP connection to destination IP | |
last_verified_fd | Hash | tgid | fd | Last fd whose IP matched a DNS-verified host |
ssl_fd_map | LRU Hash | fd | SSL connection to fd cache | |
watched_hosts | Hash | Hostname (64B) | 1 | Set of hostnames to capture DNS for |
trusted_dns_servers | Hash | IP address (16B) | 1 | Trusted DNS server whitelist |
Process and Container Tracking
| Map | Type | Key | Value | Purpose |
|---|---|---|---|---|
tracked_cgroups | Hash | cgroup inode ID | 1 | Container cgroups whose TLS writes are scanned (registered by the controller, and by the exec tracepoint for container execs) |
tracked_tgids | Hash | tgid | 1 | Container processes seen by the exec tracepoint (not used as a filter; DNS and connect tracking are node-wide) |
Program Control
| Map | Type | Key | Value | Purpose |
|---|---|---|---|---|
prog_array | ProgArray | Index 1-2 | Program FDs | Tail calls: 1=XOR patch, 2=H extract |
tc_prog_array | ProgArray | Index 0 | Program FD | TC tail call: GHASH tag recomputation |
tls_events | RingBuf | -- | Event struct (pid, tgid, len, is_rewritten) | Reserved for rewrite events to userspace (no program currently emits them) |
proc_events | RingBuf | -- | Exec/exit events | Process lifecycle events for uprobe attachment |