Skip to content

Commit 06ab3c1

Browse files
committed
feat(cdh-helper): emit app_private_key via __EIGENX_APP_PRIVATE_KEY__ sentinel
A reserved Key value makes the helper return the threshold-recovered app_private_key (hex of compressed G1 bytes) instead of a release env var, after the same eigenx-snp attested RetrieveSecretsWithOptions call. Bypasses the IBE-decrypt and the tmpfs env cache (the root is not part of the release env and must never be persisted). Lets a co-located signing daemon seed its KMS-derived root over the existing attested unseal path.
1 parent 1f5a9e7 commit 06ab3c1

2 files changed

Lines changed: 51 additions & 6 deletions

File tree

cmd/kmsCDHHelper/env_cache_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ func TestEmitKey_MissingKeyFailsLoud(t *testing.T) {
6868
assert.Contains(t, err.Error(), "not present in app env")
6969
}
7070

71+
func TestEmitKey_AppPrivateKeySentinel(t *testing.T) {
72+
// The root-key request path returns a one-entry map keyed by the sentinel
73+
// (see retrieveAndDecrypt); emitKey must serve it like any other key so the
74+
// app_private_key hex reaches stdout unchanged.
75+
rootHex := "852555c344147396974349e16f65c08dbf11b0d109e9df97afe2cfd41a84c5f34572a80bcb3053ac0ebec1693e539274"
76+
err := emitKey(map[string]string{appPrivateKeyKey: rootHex}, appPrivateKeyKey)
77+
require.NoError(t, err)
78+
}
79+
7180
func TestCacheRoundTrip(t *testing.T) {
7281
// Point the cache at a temp dir so the test doesn't touch /run/eigenx.
7382
orig := envCacheDir

cmd/kmsCDHHelper/main.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,16 @@ const (
161161
initDataPath = "/run/peerpod/initdata"
162162
)
163163

164+
// appPrivateKeyKey is a reserved Key value that makes the helper emit the
165+
// threshold-recovered app_private_key itself (hex of its compressed G1 bytes)
166+
// rather than an environment variable. This is the KMS-derived root a
167+
// signing daemon seeds from (app_private_key = S·H(appID)); it travels the
168+
// exact same eigenx-snp attestation path as a sealed env var, so it is only
169+
// ever recoverable inside an attested TEE. Because it is not part of the
170+
// release env, it bypasses the merged-env assembly, the IBE-decrypt, and the
171+
// tmpfs env cache entirely (the root must never be written to disk).
172+
const appPrivateKeyKey = "__EIGENX_APP_PRIVATE_KEY__"
173+
164174
func main() {
165175
if err := run(); err != nil {
166176
log.Printf("kmsCDHHelper: %v", err)
@@ -180,10 +190,15 @@ func run() error {
180190
// is held across the read so we don't race a concurrent first-call writer
181191
// (kata-agent unseals sequentially today, but a multi-container pod shares
182192
// the podVM). See env_cache.go.
183-
if env, ok, cerr := loadCachedEnv(req.AppID); cerr != nil {
184-
return fmt.Errorf("read env cache: %w", cerr)
185-
} else if ok {
186-
return emitKey(env, req.Key)
193+
//
194+
// The app_private_key root is never cached (it is not part of the env map),
195+
// so its request always attests fresh and never consults the cache.
196+
if req.Key != appPrivateKeyKey {
197+
if env, ok, cerr := loadCachedEnv(req.AppID); cerr != nil {
198+
return fmt.Errorf("read env cache: %w", cerr)
199+
} else if ok {
200+
return emitKey(env, req.Key)
201+
}
187202
}
188203

189204
rsaPriv, rsaPubPEM, err := generateRSAKeypair()
@@ -251,8 +266,14 @@ func run() error {
251266
// Cache the whole merged env so sibling sealed vars for this app this pod
252267
// boot hit the fast path above. Best-effort: a cache write failure must not
253268
// fail the unseal — we already have the value in hand.
254-
if cerr := storeCachedEnv(req.AppID, env); cerr != nil {
255-
log.Printf("warning: cache merged env for app %q: %v", req.AppID, cerr)
269+
//
270+
// Never cache the app_private_key root: it is not part of the release env,
271+
// and it must not be persisted to the tmpfs cache. Its request always
272+
// re-attests (see the fast-path guard above).
273+
if req.Key != appPrivateKeyKey {
274+
if cerr := storeCachedEnv(req.AppID, env); cerr != nil {
275+
log.Printf("warning: cache merged env for app %q: %v", req.AppID, cerr)
276+
}
256277
}
257278

258279
return emitKey(env, req.Key)
@@ -653,6 +674,21 @@ func retrieveAndDecrypt(
653674
return nil, fmt.Errorf("RetrieveSecretsWithOptions: %w", err)
654675
}
655676

677+
// Root-key request: emit the threshold-recovered app_private_key itself
678+
// (hex of its compressed G1 bytes) and stop. This is the KMS-derived root a
679+
// signing daemon seeds from; it does not depend on the release's
680+
// encrypted_env, so we return before the IBE-decrypt below. The key was
681+
// already validated against the master public key by RetrieveSecretsWithOptions
682+
// (VerifyAppPrivateKey), so a poisoned KMS fails there, not here.
683+
if req.Key == appPrivateKeyKey {
684+
if len(result.AppPrivateKey.CompressedBytes) == 0 {
685+
return nil, fmt.Errorf("KMS returned empty app_private_key for app %q", req.AppID)
686+
}
687+
return map[string]string{
688+
appPrivateKeyKey: hex.EncodeToString(result.AppPrivateKey.CompressedBytes),
689+
}, nil
690+
}
691+
656692
// Per the KMS design (docs/references/new_kms.md, "Application Decryption"):
657693
// the secret is the on-chain release's encrypted_env, returned in the
658694
// /secrets response and IBE-decrypted here with the threshold-recovered

0 commit comments

Comments
 (0)