secrets

package
v0.3.0-alpha Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 24, 2026 License: AGPL-3.0 Imports: 11 Imported by: 0

Documentation

Overview

Package secrets defines the boundary between kloak's control plane (which decides which secrets exist and what they should be rewritten to) and its eBPF data plane (which carries out the rewrite in-kernel). The data plane consumes any implementation of Source; the kubernetes-driven implementation lives in pkg/secrets/k8s.

Index

Constants

View Source
const (
	MinBits = 5
	MaxBits = 8
)

MinBits / MaxBits define the alphabet for shadow-tail byte selection. MinBits=5 is the absolute floor of HPACK's static Huffman table (RFC 7541 Appendix B) — there is no code shorter than 5 bits anywhere in HPACK, so reals with average density ≤ 5 bits/byte are unsupportable regardless of alphabet choice (a +7-bit fixed lower slack persists for any non-empty prefix). The validating webhook rejects such secrets at admission via secrets.CanShadow.

MaxBits=8 widens the upper window by one bit/byte vs the original [5, 7] choice. The 8-bit bucket members are 'X', 'Z' (the only two 8-bit ASCII alphanumerics) plus four 8-bit punctuation chars "&*,;" so the bucket has 6 candidates of meaningful entropy. The trade-off: shadows may include punctuation in their tail (e.g. "kl::aB&cD*"), arguably less identifier-shaped than the previous alphanumeric-only choice, but in exchange we cover short uppercase-heavy reals like "SECRET-8" (54 Huffman bits) which sit 3 bits above the [5, 7] ceiling at length 8.

Pushing MaxBits to 10+ would add more punctuation (!"()? at 10 bits) and widen the top further. We held the line at 8 because alphabet widening beyond that brings diminishing returns and increasingly weird-looking shadows — the realistic high-density real population (uppercase API keys, base64-with-special) is well covered at 8.

View Source
const (
	// ShadowPrefixLen is the length of the prefix used for BPF map key
	// collision detection. The BPF program keys lookups on the first 8
	// bytes of the shadow, so two shadows sharing those 8 bytes would
	// alias in the map.
	ShadowPrefixLen = 8

	// ValuePrefix is the literal prefix every shadow secret carries.
	// Must match what the eBPF program expects.
	//
	// Chosen at 4 bytes (27 HPACK Huffman bits) rather than the historical
	// 6-byte "kloak:" (37 bits) to widen the byte-by-byte construction's
	// feasibility window for short shadows. The +7-bit fixed slack against
	// the alphabet's 5-bit floor is the same either way (lower bound at
	// density 5+7/N), but with 27 prefix bits we get density headroom up to
	// 7−1/N at the top instead of 7−5/N — meaning hex secrets (density
	// ≈5.63) and short low-density tokens fit at much smaller N. See PR
	// description for the openssl-hex / openssl-base64 / JWT density data.
	ValuePrefix = "kl::"
)

Variables

View Source
var ErrHuffmanInvariantUnsatisfiable = errors.New("shadow Huffman bit length cannot match real's")

ErrHuffmanInvariantUnsatisfiable is returned by generateShadowValue when no shadow of the requested length can match the real secret's HPACK Huffman bit length. Kloak fails-closed on such secrets: the reconciler refuses to mint a shadow (so neither HTTP/1.1 nor HTTP/2 rewrite gets enabled) rather than producing a shadow that would silently break the HTTP/2 wire invariant. The validating webhook catches this at admission time via CanShadow so users learn at `kubectl apply` rather than at runtime.

Functions

func CanShadow

func CanShadow(originalLen, realHuffmanBits int) error

CanShadow returns nil iff a shadow can be minted for a real value of the given plaintext length and Huffman bit count. The two failure modes both surface as ErrHuffmanInvariantUnsatisfiable:

  • originalLen < len(ValuePrefix): the shadow can't even carry the "kl::" marker the BPF scanner looks for.
  • realHuffmanBits outside [prefixHuffmanBits + tailLen*MinBits, prefixHuffmanBits + tailLen*MaxBits]: the alphabet's per-byte 5–MaxBits bits/byte range can't produce a tail that matches the real's Huffman length, which means the HTTP/2 wire-buffer invariant can't be satisfied (the shadow's encoded length would differ from the real's by enough to require >7 bits of EOS padding, forbidden by RFC 7541 §5.2).

IMPORTANT — secret-leak surface: this function never sees the real value's bytes (only its Huffman bit count, computed by the caller via HuffmanBits). The returned errors are also sanitized to exclude `realHuffmanBits` — the bit count is value-derived (sum of per-byte HPACK code lengths) and the admission webhook's denial message goes back to the kubectl caller, may be captured in apiserver audit logs, and could surface in third-party SIEM pipes. Reporting only the length and the achievable range (both invariants the caller already knows, since they constructed the secret and the algorithm is public) gives users actionable diagnostics without exposing any statistic of the value's content distribution.

Used by both generateShadowValue (the actual minting path) and the validating webhook (admission-time rejection) so the predicate has a single source of truth.

func HuffmanBits

func HuffmanBits(s string) int

HuffmanBits returns the exact HPACK Huffman bit length of s — the sum of per-byte code lengths from the static table. This is the granular version of hpack.HuffmanEncodeLength (which returns bytes after rounding up), and it's what the shadow generator needs to construct a tail whose encoded length matches the real's exactly.

func ParseHost

func ParseHost(spec string) (host string, ip net.IP)

ParseHost interprets a host filter string. Empty input or "*" returns ("", nil), meaning no filter. A literal IP returns ("", parsed IP); any other non-empty value returns (hostname, nil), lowercased and without a trailing dot: the data plane compares it byte for byte with the TLS SNI, which it normalizes the same way.

Used by every Source that accepts host filters as a free-form string (k8s annotation, future YAML, ...).

Types

type Inject

type Inject struct {
	// Env is the name of an environment variable to set on the child
	// process. The value will be the secret's Shadow placeholder.
	Env string

	// File is an absolute path inside the child's filesystem where the
	// Shadow placeholder will be written. Parent directories are
	// created with 0700; the file itself is mode 0400 owned by the
	// child's uid (the runtime owns those decisions).
	File string
}

Inject describes how a Source wants the shadow value materialized for a child process. A non-runtime consumer (e.g. the k8s adapter) returns the zero value and the field is ignored.

Both Env and File may be set on the same Secret — the child will see the shadow placeholder in both the named env var AND the file at the given path. Empty in both fields means "no injection requested"; runtimes should treat that as a validation error before they get here.

type PortSpec

type PortSpec struct {
	Port     uint16
	Protocol uint8 // unix.IPPROTO_TCP or unix.IPPROTO_UDP
}

PortSpec is the parsed form of a "<port>" or "<port>/<proto>" string.

func ParsePort

func ParsePort(spec string) (PortSpec, error)

ParsePort parses "443" or "443/tcp" / "53/udp". Defaults to TCP when the protocol is omitted. Empty input is invalid; callers that treat "no port filter" as wildcard should check the empty case before calling.

type Ref

type Ref struct {
	OwnerID string
	Keys    []string
}

Ref names the secret keys a workload is entitled to redeem: the data plane rewrites a placeholder only for processes of a workload holding a Ref to it. OwnerID matches Secret.OwnerID. Keys lists the data keys the workload references; nil means every key of the owner.

type Secret

type Secret struct {
	// OwnerID identifies the logical producer of this secret. Used for
	// 8-byte-prefix collision tracking when generating shadows; two
	// entries with the same OwnerID are treated as siblings (a shadow
	// being regenerated for the same owner is not a collision with its
	// own previous value).
	OwnerID string

	// Key is a sub-identifier within an owner: a Kubernetes Secret can
	// have multiple data keys; YAML entries today have one key per
	// entry. The (OwnerID, Key) pair is unique within a snapshot.
	Key string

	// Real is the real secret value. The kernel rewrites Shadow → Real
	// in TLS writes that match the Host/IP/Port filters below.
	Real string

	// Shadow is the placeholder the application sees and the BPF map
	// keys off (the first 8 bytes are the lookup key).
	Shadow string

	// Host is a hostname filter. If non-empty, the rewrite happens only
	// on connections made to the hostname's virtual IP, which kloak hands
	// the pod in DNS answers for it. Empty means any host.
	Host string

	// IP is an alternative to Host: a literal destination IP, matched
	// against the address the connection was made to (after the virtual
	// IP redirect), with no DNS involved. Mutually exclusive with Host (k8s
	// adapter populates this when Host parses as a literal IP).
	IP net.IP

	// Port is a TCP/UDP port filter. 0 means any port.
	Port uint16

	// Protocol is unix.IPPROTO_TCP / IPPROTO_UDP, or 0 for any.
	Protocol uint8

	// Inject describes how the standalone `kloak run` runtime should
	// surface this secret's *shadow* value to a child process. The k8s
	// adapter leaves this zero — Kubernetes Pods already get their
	// secret values through the cluster's own injection mechanisms
	// (volume mounts, env vars rewritten by the webhook), so the
	// data-plane sync code never reads this field. The YAML / dotenv /
	// host-env Sources for `kloak run` set it from the user's config.
	Inject Inject
}

Secret describes one secret pair the data plane should program into its BPF map: a real value that the kernel rewrites to, the shadow placeholder the application sees in user space, and the filter constraints under which the rewrite is allowed.

The struct is intentionally a *semantic* representation, not the BPF wire layout. The data plane in pkg/ebpf/sync.go translates a []Secret into the fixed-size struct secretValue at sync time.

type ShadowGenerator

type ShadowGenerator struct {
	// contains filtered or unexported fields
}

ShadowGenerator mints shadow values that don't 8-byte-prefix-collide with any other owner already known to it. Each Source supplies its own seed via NewShadowGenerator: the k8s adapter seeds from a List of existing shadow secrets in the cluster; a file-backed source seeds from the entries it just parsed (no cross-process universe to worry about).

The collision-tracking state is owned by the generator instead of being passed as a parameter on every call, so each Source has a natural place to keep it for the duration of its lifetime.

func NewShadowGenerator

func NewShadowGenerator(seed map[string]map[string]struct{}, log *zap.SugaredLogger) *ShadowGenerator

NewShadowGenerator returns a generator seeded with the given prefix occupancy map. Pass nil for an empty seed (e.g. fresh load from a YAML file). Pass a non-nil map to start from a known set of in-use prefixes (e.g. shadows that already exist in the k8s cluster).

The generator takes ownership of the map; callers should not mutate it after this call.

func (*ShadowGenerator) Collides

func (g *ShadowGenerator) Collides(shadow, ownerID string) bool

Collides reports whether shadow's 8-byte prefix is occupied by any owner other than ownerID. Exposed for callers (e.g. the k8s reconciler) that want to validate a candidate before generating.

func (*ShadowGenerator) Generate

func (g *ShadowGenerator) Generate(originalLen, realHuffmanBits int, ownerID string, maxRetries int) (string, error)

Generate returns a shadow value of exactly originalLen bytes whose 8-byte prefix is occupied by NO owner currently known to the generator. A freshly-minted shadow must be globally unique inside the generator's universe — otherwise two keys of the same Secret (which share an ownerID) could land on the same prefix because an owner-excluded check would let it through. Distinct from Collides, which keeps the owner-exclusion semantic for the "reuse my own existing shadow" decision.

On success the chosen prefix is recorded under ownerID so subsequent Generate calls within the same generator avoid it. Retries up to maxRetries times before giving up.

realHuffmanBits is the EXACT HPACK Huffman bit length of the real secret value the caller wants to shadow — computed by the caller via HuffmanBits(realSecret). It's a bit count, not a byte count: the byte-by-byte construction guarantees a shadow whose Huffman bit length equals realHuffmanBits exactly, which means the encoded byte length matches as well (no padding required). Passing the bit count instead of the cleartext keeps the real secret out of this package's scope entirely.

func (*ShadowGenerator) Record

func (g *ShadowGenerator) Record(shadow, ownerID string)

Record marks shadow as in use by ownerID. Sources call this after persisting a generated shadow so that subsequent Generate calls within the same generator avoid the prefix.

type Source

type Source interface {
	Snapshot(ctx context.Context) ([]Secret, error)
}

Source produces snapshots of the secrets the data plane should program into its BPF maps. Implementations may be backed by a Kubernetes informer cache, a YAML file, or any other origin.

Snapshot is called repeatedly by pkg/ebpf/sync.go; implementations should be cheap and re-entrant. Returned slices must not be mutated by the caller.

Directories

Path Synopsis
Package k8s implements secrets.Source over a Kubernetes informer cache.
Package k8s implements secrets.Source over a Kubernetes informer cache.
Package yaml is one concrete `secrets.Source` implementation: it reads a kloak-native YAML file and emits the canonical `[]secrets.Secret` slice the data plane consumes.
Package yaml is one concrete `secrets.Source` implementation: it reads a kloak-native YAML file and emits the canonical `[]secrets.Secret` slice the data plane consumes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL