Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 21 additions & 52 deletions cmd/kmsCDHHelper/env_cache.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package main

// Per-app environment assembly + a per-pod-boot cache.
// Per-stack environment cache (per-pod-boot).
//
// The eigenx CDH plugin calls this helper once per sealed env var in the pod
// spec. Each invocation is a fresh process, so to keep attestation to ONE round
// trip per pod (not one per env var) the first call caches the whole merged
// environment to a tmpfs file and every later call for the same app serves its
// requested key straight from that cache.
// trip per pod (not one per env var) the first call caches the whole assembled
// stack environment to a tmpfs file and every later call for the same stack
// serves its requested key straight from that cache.
//
// The cache holds plaintext secrets at rest, so it MUST live on memory-backed
// tmpfs inside the SEV-SNP guest (never the disk image), with owner-only perms.
// /run is tmpfs in the podVM. The file is scoped per app_id and naturally
// /run is tmpfs in the podVM. The file is scoped per stack_id and naturally
// cleared on pod restart (tmpfs is volatile), giving per-boot freshness.

import (
Expand Down Expand Up @@ -39,42 +39,6 @@ const (
cacheDirMode os.FileMode = 0o700
)

// mergeEnv overlays the decrypted secret env on top of the release's public
// env. Both are JSON objects of string→string. public_env is plaintext config
// from the on-chain release; secretPlaintext is the IBE-decrypted encrypted_env.
// Secret keys win on collision so a public default can never shadow a secret.
//
// Either side may be empty. publicEnvJSON is "" when the release pins no public
// env; secretPlaintext is empty when the release has no encrypted_env (a
// public-only release). A release with neither yields an empty map. When
// present, each side must be a JSON object: the env is a flat key→value map,
// which is what lets CDH address individual variables by name.
func mergeEnv(publicEnvJSON string, secretPlaintext []byte) (map[string]string, error) {
env := map[string]string{}

if strings.TrimSpace(publicEnvJSON) != "" {
var pub map[string]string
if err := json.Unmarshal([]byte(publicEnvJSON), &pub); err != nil {
return nil, fmt.Errorf("public_env is not a JSON string map: %w", err)
}
for k, v := range pub {
env[k] = v
}
}

if len(secretPlaintext) > 0 {
var sec map[string]string
if err := json.Unmarshal(secretPlaintext, &sec); err != nil {
return nil, fmt.Errorf("decrypted encrypted_env is not a JSON string map: %w", err)
}
for k, v := range sec { // secret overrides public
env[k] = v
}
}

return env, nil
}

// emitKey writes the value for key to stdout (the unseal_secret return that
// kata-agent substitutes into the one sealed env var that triggered this call).
// A missing key is a hard error: the deployer listed a sealed env var whose
Expand All @@ -91,20 +55,25 @@ func emitKey(env map[string]string, key string) error {
return nil
}

func cachePath(appID string) string {
// app_id is an Ethereum address (0x + 40 hex) in production and a short
// slug under fakeKMS; both are filesystem-safe. Sanitize anyway so a
// surprising app_id can't escape envCacheDir via path separators.
safe := strings.NewReplacer("/", "_", "..", "_", string(os.PathSeparator), "_").Replace(appID)
func cachePath(stackID string) string {
// stack_id is a UUID/slug (content-validated in applyInitdataKMSConfig) and
// is filesystem-safe. Sanitize path separators anyway so a surprising
// stack_id can't escape envCacheDir. We deliberately do NOT substitute the
// substring ".." here: replacing it would map two distinct valid IDs
// (e.g. "ver..2" and "ver_2") to the same cache file, silently serving one
// stack's env to the other. Traversal is not a risk to defend here anyway —
// validateStackID rejects the exact "." / ".." segments, and stripping the
// separators below means no ".." can act as a traversal component.
safe := strings.NewReplacer("/", "_", string(os.PathSeparator), "_").Replace(stackID)
return filepath.Join(envCacheDir, safe+".json")
}

// loadCachedEnv returns the cached merged env for appID if a prior call this
// loadCachedEnv returns the cached stack env for stackID if a prior call this
// pod boot already fetched it. ok=false (nil error) means cache miss — the
// caller should attest + fetch. A shared (read) flock is held across the read
// so a concurrent first-call writer can't be observed mid-write.
func loadCachedEnv(appID string) (env map[string]string, ok bool, err error) {
f, err := os.Open(cachePath(appID))
func loadCachedEnv(stackID string) (env map[string]string, ok bool, err error) {
f, err := os.Open(cachePath(stackID))
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
Expand All @@ -127,15 +96,15 @@ func loadCachedEnv(appID string) (env map[string]string, ok bool, err error) {
return env, true, nil
}

// storeCachedEnv writes the merged env to the per-app tmpfs cache atomically
// storeCachedEnv writes the stack env to the per-stack tmpfs cache atomically
// (temp file + rename) under an exclusive flock, so a reader either sees the
// old file or the fully-written new one, never a partial write.
func storeCachedEnv(appID string, env map[string]string) error {
func storeCachedEnv(stackID string, env map[string]string) error {
if err := os.MkdirAll(envCacheDir, cacheDirMode); err != nil {
return fmt.Errorf("mkdir %s: %w", envCacheDir, err)
}

final := cachePath(appID)
final := cachePath(stackID)

// Serialize writers on a lock file co-located with the target. We lock a
// dedicated .lock (not the target) so the atomic rename below can't swap
Expand Down
96 changes: 38 additions & 58 deletions cmd/kmsCDHHelper/env_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/hex"
"io"
"os"
"path/filepath"
"strings"
Expand All @@ -13,57 +14,6 @@ import (
"github.com/stretchr/testify/require"
)

func TestMergeEnv_SecretWinsOverPublic(t *testing.T) {
public := `{"LOG_LEVEL":"info","SHARED":"public-value"}`
secret := []byte(`{"DB_PASSWORD":"hunter2","SHARED":"secret-value"}`)

env, err := mergeEnv(public, secret)
require.NoError(t, err)

assert.Equal(t, "info", env["LOG_LEVEL"], "public-only key preserved")
assert.Equal(t, "hunter2", env["DB_PASSWORD"], "secret-only key preserved")
assert.Equal(t, "secret-value", env["SHARED"], "secret must override public on collision")
assert.Len(t, env, 3)
}

func TestMergeEnv_EmptyPublic(t *testing.T) {
env, err := mergeEnv("", []byte(`{"A":"1","B":"2"}`))
require.NoError(t, err)
assert.Equal(t, map[string]string{"A": "1", "B": "2"}, env)
}

func TestMergeEnv_PublicOnly_EmptySecret(t *testing.T) {
// A public-only release (no encrypted_env) passes an empty secretPlaintext.
// The public env must come through and there must be no JSON-parse error.
env, err := mergeEnv(`{"LOG_LEVEL":"info","ENVIRONMENT":"prod"}`, nil)
require.NoError(t, err)
assert.Equal(t, map[string]string{"LOG_LEVEL": "info", "ENVIRONMENT": "prod"}, env)

env, err = mergeEnv(`{"LOG_LEVEL":"info"}`, []byte{})
require.NoError(t, err)
assert.Equal(t, map[string]string{"LOG_LEVEL": "info"}, env)
}

func TestMergeEnv_BothEmpty(t *testing.T) {
env, err := mergeEnv("", nil)
require.NoError(t, err)
assert.Empty(t, env)
}

func TestMergeEnv_RejectsNonObjectSecret(t *testing.T) {
// A bare string (the old single-secret shape) is no longer valid — the
// decrypted blob must be a JSON object so keys are addressable.
_, err := mergeEnv("", []byte(`"just-a-string"`))
require.Error(t, err)
assert.Contains(t, err.Error(), "JSON string map")
}

func TestMergeEnv_RejectsBadPublic(t *testing.T) {
_, err := mergeEnv(`{not json}`, []byte(`{"A":"1"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "public_env")
}

func TestEmitKey_MissingKeyFailsLoud(t *testing.T) {
// A pod-spec sealed var whose name isn't in the release env is a
// misconfiguration; emitKey must error rather than inject "".
Expand All @@ -72,6 +22,25 @@ func TestEmitKey_MissingKeyFailsLoud(t *testing.T) {
assert.Contains(t, err.Error(), "not present in app env")
}

func TestEmitKey_HappyPathWritesValueToStdout(t *testing.T) {
// The success path writes exactly the key's value (no trailing newline) to
// stdout — that raw byte stream is the unseal_secret return kata-agent
// substitutes into the sealed env var.
orig := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() { os.Stdout = orig }()

emitErr := emitKey(map[string]string{"FOO": "bar", "OTHER": "x"}, "FOO")
require.NoError(t, w.Close())
require.NoError(t, emitErr)

out, err := io.ReadAll(r)
require.NoError(t, err)
assert.Equal(t, "bar", string(out))
}

func TestCacheable_AppPrivateKeyNeverCached(t *testing.T) {
// The root key must bypass the tmpfs env cache entirely (never read, never
// written); any other key is cacheable. This is the invariant both cache
Expand Down Expand Up @@ -137,33 +106,44 @@ func TestCacheRoundTrip(t *testing.T) {
setEnvCacheDir(tmp)
defer setEnvCacheDir(orig)

appID := "0xabc123app"
stackID := "stack-abc123"
want := map[string]string{"DB_PASSWORD": "hunter2", "API_KEY": "sk-xyz"}

// Miss before any write.
_, ok, err := loadCachedEnv(appID)
_, ok, err := loadCachedEnv(stackID)
require.NoError(t, err)
assert.False(t, ok, "expected cache miss before store")

require.NoError(t, storeCachedEnv(appID, want))
require.NoError(t, storeCachedEnv(stackID, want))

got, ok, err := loadCachedEnv(appID)
got, ok, err := loadCachedEnv(stackID)
require.NoError(t, err)
require.True(t, ok, "expected cache hit after store")
assert.Equal(t, want, got)

// File is owner-only on tmpfs.
info, err := os.Stat(filepath.Join(tmp, "0xabc123app.json"))
info, err := os.Stat(filepath.Join(tmp, "stack-abc123.json"))
require.NoError(t, err)
assert.Equal(t, cacheFileMode, info.Mode().Perm())
}

func TestCachePath_SanitizesAppID(t *testing.T) {
func TestCachePath_SanitizesStackID(t *testing.T) {
orig := envCacheDir
setEnvCacheDir("/run/eigenx")
defer setEnvCacheDir(orig)

// A path-separator in app_id must not escape the cache dir.
// A path-separator in stack_id must not escape the cache dir.
p := cachePath("../../etc/evil")
assert.True(t, filepath.Dir(p) == "/run/eigenx", "cache path must stay in envCacheDir, got %s", p)
}

func TestCachePath_DistinctValidIDsDoNotCollide(t *testing.T) {
// Two valid, distinct stack_ids must map to distinct cache files. "ver..2"
// and "ver_2" both pass validateStackID; an earlier ".." -> "_" substitution
// collapsed them onto the same file, which would silently serve one stack's
// env to the other. Guard against that regression.
require.NoError(t, validateStackID("ver..2"))
require.NoError(t, validateStackID("ver_2"))
assert.NotEqual(t, cachePath("ver..2"), cachePath("ver_2"),
"distinct valid stack_ids must not share a cache file")
}
Loading
Loading