From 177e903620924872b344e394f0cc0bf2b8ee4010 Mon Sep 17 00:00:00 2001 From: redcourage Date: Thu, 7 May 2026 22:46:44 +0900 Subject: [PATCH] fix(keys): KeysExist must respect WithKeyParts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background ---------- Vault delivers only the Encryption-side material (EncKey) to clients via the agent manifest — Eval/Sec stay inside Vault for FHE evaluation and re-encryption. A consumer reading that bundle calls OpenKeysFromFile(WithKeyPath(...), ..., WithKeyParts(KeyPartEnc)) OpenKeysFromFile begins with a `KeysExist(opts...)` gate; if it returns false, the open fails fast with ErrKeysNotFound. Bug --- KeysExist walked all three slots (Enc / Eval / Sec) unconditionally, ignoring the caller's WithKeyParts. So the Vault-delivered Enc-only bundle was rejected even when the caller had explicitly said "I only need Enc". Reproducer (the path the rune-mcp boot loop hit on first GetAgentManifest): dir := t.TempDir() os.WriteFile(filepath.Join(dir, "EncKey.json"), []byte("{}"), 0o600) KeysExist( WithKeyPath(dir), WithKeyID("k"), WithKeyDim(1024), WithKeyParts(KeyPartEnc), ) // pre-fix: false → bundle rejected // fixed: true → bundle accepted Fix --- KeysExist now resolves the requested KeyParts via the same helper OpenKeysFromFile uses (resolveKeyParts) and only checks the slots the caller actually requested. Default behavior (no WithKeyParts) is preserved: resolveKeyParts returns enc=true,eval=true,sec=true, so a bare KeysExist(WithKeyPath(...)) still requires all three. Tests ----- TestKeysExist_PartsAware covers: - enc-only dir + WithKeyParts(KeyPartEnc) → true (Vault use case) - enc-only dir + default parts → false (backward compat) - enc-only dir + WithKeyParts(KeyPartEval) → false (other parts still missing) Existing TestKeysExist_FalseWhenEmpty / TestGenerateKeys_CreatesAllThreeFiles keep their prior expectations — both are exercised through the same resolveKeyParts code path now. Discovered while wiring rune-mcp v0.4 (Go port) against Vault's GetAgentManifest flow; the boot loop's envector adapter opens keys with KeyPartEnc only and was bouncing on this gate every retry. Co-Authored-By: Claude Opus 4.7 (1M context) --- keys.go | 18 ++++++++++++------ keys_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/keys.go b/keys.go index 410bf90..f29dabc 100644 --- a/keys.go +++ b/keys.go @@ -118,13 +118,19 @@ func KeysExist(opts ...KeysOption) bool { if o.Path == "" { return false } - slots := [3][2]string{ - {encKeyBinFile, encKeyJSONFile}, - {evalKeyBinFile, evalKeyJSONFile}, - {secKeyBinFile, secKeyJSONFile}, + wantEnc, wantEval, wantSec := resolveKeyParts(o.Parts) + if wantEnc { + if _, _, ok := resolveKeySlot(o.Path, encKeyBinFile, encKeyJSONFile); !ok { + return false + } } - for _, s := range slots { - if _, _, ok := resolveKeySlot(o.Path, s[0], s[1]); !ok { + if wantEval { + if _, _, ok := resolveKeySlot(o.Path, evalKeyBinFile, evalKeyJSONFile); !ok { + return false + } + } + if wantSec { + if _, _, ok := resolveKeySlot(o.Path, secKeyBinFile, secKeyJSONFile); !ok { return false } } diff --git a/keys_test.go b/keys_test.go index 4d1830e..635066d 100644 --- a/keys_test.go +++ b/keys_test.go @@ -320,3 +320,44 @@ func TestRegisterKeys_WithoutEvalPart_ReturnsErr(t *testing.T) { t.Errorf("ActivateKeys without KeyPartEval: got %v, want ErrKeysNotForRegister", err) } } + +// TestKeysExist_PartsAware exercises the KeyParts-aware lookup. Vault's +// agent-manifest delivery only ships EncKey to the client (Eval/Sec stay +// in Vault), so the consumer opens that directory with +// WithKeyParts(KeyPartEnc) and expects KeysExist to return true on an +// Enc-only directory. The previous implementation walked all three slots +// unconditionally and rejected the bundle. +func TestKeysExist_PartsAware(t *testing.T) { + encOnly := func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, encKeyJSONFile), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + return dir + } + + t.Run("enc-only dir + WithKeyParts(KeyPartEnc) → true", func(t *testing.T) { + dir := encOnly(t) + opts := append(baseKeyOpts(dir), WithKeyParts(KeyPartEnc)) + if !KeysExist(opts...) { + t.Error("KeysExist with KeyPartEnc must accept Enc-only directory") + } + }) + + t.Run("enc-only dir + default parts (= all three) → false", func(t *testing.T) { + dir := encOnly(t) + // No WithKeyParts → resolveKeyParts treats it as all three required. + if KeysExist(baseKeyOpts(dir)...) { + t.Error("default parts must require all 3 slots") + } + }) + + t.Run("enc-only dir + WithKeyParts(KeyPartEval) → false (eval missing)", func(t *testing.T) { + dir := encOnly(t) + opts := append(baseKeyOpts(dir), WithKeyParts(KeyPartEval)) + if KeysExist(opts...) { + t.Error("requesting Eval on an Enc-only dir must fail") + } + }) +}