mcp-framework/docs/superpowers/plans/2026-05-02-bitwarden-cache.md

1357 lines
35 KiB
Markdown
Raw Normal View History

# Bitwarden Cache Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a secure, portable Bitwarden secret cache with memory-first reads and encrypted disk persistence derived from `BW_SESSION`.
**Architecture:** The cache is internal to `secretstore` and used only by the `bitwarden-cli` backend. `GetSecret` reads memory, encrypted disk, then Bitwarden CLI; successful CLI reads fill both caches. Manifest and generated helper options carry a default-enabled `bitwarden_cache` option, with an environment override for operational disablement.
**Tech Stack:** Go 1.25 standard library (`crypto/aes`, `crypto/cipher`, `crypto/hkdf`, `crypto/hmac`, `crypto/rand`, `crypto/sha256`, `encoding/json`, `os`, `time`), existing `manifest`, `secretstore`, and `generate` packages.
---
## File Structure
- Create `secretstore/bitwarden_cache.go`: cache implementation, key derivation, disk encryption, TTL, path resolution, env override parsing.
- Create `secretstore/bitwarden_cache_test.go`: focused cache tests independent from the Bitwarden fake CLI when possible.
- Modify `secretstore/bitwarden.go`: add cache field to `bitwardenStore`, initialize it in `newBitwardenStore`, and use it in `GetSecret`, `SetSecret`, `DeleteSecret`.
- Modify `secretstore/store.go`: add cache options and defaults to `Options`.
- Modify `secretstore/manifest_open.go`: resolve `manifest.SecretStore.BitwardenCache` into `OpenFromManifestOptions` and `Options`.
- Modify `secretstore/runtime.go`: pass cache option through runtime describe/preflight paths.
- Modify `manifest/manifest.go`: parse and normalize `[secret_store].bitwarden_cache` with tri-state semantics so omission means enabled.
- Modify `manifest/manifest_test.go`: test parsing explicit false and omitted default behavior.
- Modify `secretstore/manifest_open_test.go`: test manifest false disables Bitwarden cache.
- Modify `generate/generate.go`: expose `DisableBitwardenCache` in generated `SecretStoreOptions` and pass it to framework options.
- Modify `generate/generate_test.go`: assert generated helper contains the new option.
- Modify `docs/manifest.md` and `docs/secrets.md`: document cache default, disable controls, TTL, and threat model.
## Manifest Option Shape
Use a pointer bool in `manifest.SecretStore` so the code can distinguish omitted from explicit false:
```go
type SecretStore struct {
BackendPolicy string `toml:"backend_policy"`
BitwardenCache *bool `toml:"bitwarden_cache"`
}
```
Use a value bool named `DisableBitwardenCache` in runtime option structs. This preserves default-enabled zero-value behavior while still allowing callers to disable the cache programmatically. The manifest resolver applies `bitwarden_cache = false`, then the runtime disable flag and environment override can force disablement.
---
### Task 1: Manifest Parses `bitwarden_cache`
**Files:**
- Modify: `manifest/manifest.go`
- Test: `manifest/manifest_test.go`
- [ ] **Step 1: Write the failing manifest test**
Add this to `manifest/manifest_test.go`:
```go
func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
[secret_store]
backend_policy = "bitwarden-cli"
bitwarden_cache = false
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if file.SecretStore.BitwardenCache == nil {
t.Fatal("bitwarden cache option is nil, want explicit false pointer")
}
if *file.SecretStore.BitwardenCache {
t.Fatal("bitwarden cache option = true, want false")
}
}
func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
[secret_store]
backend_policy = "bitwarden-cli"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if file.SecretStore.BitwardenCache != nil {
t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset'
```
Expected: FAIL because `SecretStore.BitwardenCache` does not exist.
- [ ] **Step 3: Implement manifest field**
In `manifest/manifest.go`, change `SecretStore` to:
```go
type SecretStore struct {
BackendPolicy string `toml:"backend_policy"`
BitwardenCache *bool `toml:"bitwarden_cache"`
}
```
Keep `normalize` unchanged except for preserving the pointer:
```go
func (s *SecretStore) normalize() {
s.BackendPolicy = strings.TrimSpace(s.BackendPolicy)
}
```
- [ ] **Step 4: Run manifest tests**
Run:
```bash
go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset'
```
Expected: PASS.
- [ ] **Step 5: Commit**
Run:
```bash
git add manifest/manifest.go manifest/manifest_test.go
git commit -m "feat: parse bitwarden cache manifest option"
```
---
### Task 2: Add Runtime Cache Option Plumbing
**Files:**
- Modify: `secretstore/store.go`
- Modify: `secretstore/manifest_open.go`
- Modify: `secretstore/runtime.go`
- Test: `secretstore/manifest_open_test.go`
- [ ] **Step 1: Write failing manifest plumbing test**
Add this to `secretstore/manifest_open_test.go`:
```go
func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) {
cacheDisabled := false
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
ServiceName: "email-mcp",
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{
BackendPolicy: string(BackendBitwardenCLI),
BitwardenCache: &cacheDisabled,
},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("resolveManifestPolicy returned error: %v", err)
}
if resolution.BitwardenCache {
t.Fatal("resolution BitwardenCache = true, want false")
}
if resolution.Policy != BackendBitwardenCLI {
t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable
```
Expected: FAIL because cache option fields are not wired.
- [ ] **Step 3: Add option fields**
In `secretstore/store.go`, update `Options`:
```go
type Options struct {
ServiceName string
BackendPolicy BackendPolicy
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
Shell string
}
```
In `secretstore/manifest_open.go`, update `OpenFromManifestOptions`:
```go
type OpenFromManifestOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
}
```
Add a field to `manifestPolicyResolution`:
```go
type manifestPolicyResolution struct {
Policy BackendPolicy
Source string
BitwardenCache bool
}
```
Where `resolveManifestPolicy` returns missing-manifest or omitted-field defaults, set `BitwardenCache: true`. When manifest has an explicit pointer, use it:
```go
bitwardenCache := true
if file.SecretStore.BitwardenCache != nil {
bitwardenCache = *file.SecretStore.BitwardenCache
}
```
Pass it in `OpenFromManifest`:
```go
DisableBitwardenCache: options.DisableBitwardenCache || !manifestPolicy.BitwardenCache,
```
This keeps `OpenFromManifestOptions{}` default-enabled when the manifest omits the field, while still respecting both `bitwarden_cache = false` and a caller's explicit disable flag. If a helper is clearer, add:
```go
func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool {
return runtimeDisabled || !manifestEnabled
}
```
Call it as:
```go
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache),
```
- [ ] **Step 4: Pass through runtime describe options**
In `secretstore/runtime.go`, add `DisableBitwardenCache bool` to `DescribeRuntimeOptions`, and pass it to `Open`.
Use:
```go
DisableBitwardenCache: options.DisableBitwardenCache,
```
- [ ] **Step 5: Run plumbing test**
Run:
```bash
go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable
```
Expected: PASS.
- [ ] **Step 6: Commit compile-safe plumbing**
Run the broader compile target first:
```bash
go test ./secretstore ./manifest
```
Expected: PASS.
Then run:
```bash
git add secretstore/store.go secretstore/manifest_open.go secretstore/runtime.go secretstore/manifest_open_test.go
git commit -m "feat: wire bitwarden cache options"
```
---
### Task 3: Implement Cache Core
**Files:**
- Create: `secretstore/bitwarden_cache.go`
- Create: `secretstore/bitwarden_cache_test.go`
- [ ] **Step 1: Write cache core tests**
Create `secretstore/bitwarden_cache_test.go`:
```go
package secretstore
import (
"bytes"
"os"
"strings"
"testing"
"time"
)
func TestBitwardenCacheMemoryHit(t *testing.T) {
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) },
CacheDir: t.TempDir(),
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
got, ok := cache.loadMemory("api-token", "email-mcp/api-token")
if !ok {
t.Fatal("memory cache miss, want hit")
}
if got != "secret-v1" {
t.Fatalf("memory cache value = %q, want secret-v1", got)
}
}
func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return now },
CacheDir: dir,
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
reopened := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return now.Add(time.Minute) },
CacheDir: dir,
Enabled: true,
})
got, ok := reopened.loadDisk("api-token", "email-mcp/api-token")
if !ok {
t.Fatal("disk cache miss, want hit")
}
if got != "secret-v1" {
t.Fatalf("disk cache value = %q, want secret-v1", got)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir cache dir: %v", err)
}
if len(entries) != 1 {
t.Fatalf("cache file count = %d, want 1", len(entries))
}
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("ReadFile cache file: %v", err)
}
if bytes.Contains(data, []byte("secret-v1")) {
t.Fatalf("cache file contains plaintext secret: %s", data)
}
if strings.Contains(entries[0].Name(), "api-token") {
t.Fatalf("cache file name exposes secret name: %s", entries[0].Name())
}
}
func TestBitwardenCacheRejectsChangedSession(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return now },
CacheDir: dir,
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
changed := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v2",
TTL: 10 * time.Minute,
Now: func() time.Time { return now.Add(time.Minute) },
CacheDir: dir,
Enabled: true,
})
if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok {
t.Fatalf("disk cache hit with changed session = %q, want miss", got)
}
}
func TestBitwardenCacheExpiresEntries(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: time.Minute,
Now: func() time.Time { return now },
CacheDir: dir,
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
expired := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: time.Minute,
Now: func() time.Time { return now.Add(2 * time.Minute) },
CacheDir: dir,
Enabled: true,
})
if got, ok := expired.load("api-token", "email-mcp/api-token"); ok {
t.Fatalf("expired cache hit = %q, want miss", got)
}
}
```
If `filepath` is missing, add it to the imports.
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
go test ./secretstore -run 'TestBitwardenCache'
```
Expected: FAIL because cache types/functions do not exist.
- [ ] **Step 3: Implement cache core**
Create `secretstore/bitwarden_cache.go`:
```go
package secretstore
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE"
defaultBitwardenCacheTTL = 10 * time.Minute
bitwardenCacheFormatVersion = 1
bitwardenCacheAlgorithm = "AES-256-GCM"
bitwardenCacheDirName = "bitwarden-cache"
bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1"
bitwardenCacheInfo = "mcp-framework bitwarden cache v1"
bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1"
bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1"
bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1"
)
type bitwardenCacheOptions struct {
ServiceName string
Session string
TTL time.Duration
Now func() time.Time
CacheDir string
Enabled bool
}
type bitwardenCache struct {
mu sync.Mutex
enabled bool
serviceName string
ttl time.Duration
now func() time.Time
cacheDir string
encryptionKey []byte
entryIDKey []byte
memory map[string]bitwardenCacheMemoryEntry
}
type bitwardenCacheMemoryEntry struct {
value string
expiresAt time.Time
}
type bitwardenCachePlaintext struct {
Version int `json:"version"`
ServiceName string `json:"service_name"`
SecretName string `json:"secret_name"`
ScopedName string `json:"scoped_name"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Value string `json:"value"`
}
type bitwardenCacheEnvelope struct {
Version int `json:"version"`
Algorithm string `json:"algorithm"`
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
}
func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache {
now := options.Now
if now == nil {
now = time.Now
}
ttl := options.TTL
if ttl <= 0 {
ttl = defaultBitwardenCacheTTL
}
cache := &bitwardenCache{
enabled: options.Enabled,
serviceName: strings.TrimSpace(options.ServiceName),
ttl: ttl,
now: now,
cacheDir: strings.TrimSpace(options.CacheDir),
memory: map[string]bitwardenCacheMemoryEntry{},
}
if !cache.enabled {
return cache
}
session := strings.TrimSpace(options.Session)
if session == "" {
return cache
}
masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32)
if err != nil {
return cache
}
cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32)
if err != nil {
cache.encryptionKey = nil
return cache
}
cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32)
if err != nil {
cache.encryptionKey = nil
cache.entryIDKey = nil
return cache
}
return cache
}
func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) {
if value, ok := c.loadMemory(secretName, scopedName); ok {
return value, true
}
return c.loadDisk(secretName, scopedName)
}
func (c *bitwardenCache) store(secretName, scopedName, value string) {
if c == nil || !c.enabled {
return
}
c.storeMemory(secretName, scopedName, value)
c.storeDisk(secretName, scopedName, value)
}
func (c *bitwardenCache) invalidate(secretName, scopedName string) {
if c == nil {
return
}
key := c.memoryKey(secretName, scopedName)
c.mu.Lock()
delete(c.memory, key)
c.mu.Unlock()
if path, ok := c.entryPath(secretName, scopedName); ok {
_ = os.Remove(path)
}
}
func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) {
if c == nil || !c.enabled {
return "", false
}
key := c.memoryKey(secretName, scopedName)
now := c.now()
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.memory[key]
if !ok {
return "", false
}
if !entry.expiresAt.After(now) {
delete(c.memory, key)
return "", false
}
return entry.value, true
}
func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) {
key := c.memoryKey(secretName, scopedName)
c.mu.Lock()
c.memory[key] = bitwardenCacheMemoryEntry{
value: value,
expiresAt: c.now().Add(c.ttl),
}
c.mu.Unlock()
}
func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) {
path, ok := c.entryPath(secretName, scopedName)
if !ok {
return "", false
}
data, err := os.ReadFile(path)
if err != nil {
return "", false
}
var envelope bitwardenCacheEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
_ = os.Remove(path)
return "", false
}
plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope)
if err != nil {
_ = os.Remove(path)
return "", false
}
if plaintext.Version != bitwardenCacheFormatVersion ||
plaintext.ServiceName != c.serviceName ||
plaintext.SecretName != strings.TrimSpace(secretName) ||
plaintext.ScopedName != strings.TrimSpace(scopedName) ||
!plaintext.ExpiresAt.After(c.now()) {
_ = os.Remove(path)
return "", false
}
c.storeMemory(secretName, scopedName, plaintext.Value)
return plaintext.Value, true
}
func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) {
if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 {
return
}
path, ok := c.entryPath(secretName, scopedName)
if !ok {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
_ = os.Chmod(filepath.Dir(path), 0o700)
now := c.now()
plaintext := bitwardenCachePlaintext{
Version: bitwardenCacheFormatVersion,
ServiceName: c.serviceName,
SecretName: strings.TrimSpace(secretName),
ScopedName: strings.TrimSpace(scopedName),
CreatedAt: now,
ExpiresAt: now.Add(c.ttl),
Value: value,
}
envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext)
if err != nil {
return
}
data, err := json.Marshal(envelope)
if err != nil {
return
}
tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp")
if err != nil {
return
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
_ = tmp.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
_ = tmp.Chmod(0o600)
if _, err := tmp.Write(data); err != nil {
return
}
if err := tmp.Close(); err != nil {
return
}
if err := os.Rename(tmpPath, path); err != nil {
return
}
_ = os.Chmod(path, 0o600)
cleanup = false
}
func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) {
raw, err := json.Marshal(plaintext)
if err != nil {
return bitwardenCacheEnvelope{}, err
}
block, err := aes.NewCipher(c.encryptionKey)
if err != nil {
return bitwardenCacheEnvelope{}, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return bitwardenCacheEnvelope{}, err
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return bitwardenCacheEnvelope{}, err
}
ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName))
return bitwardenCacheEnvelope{
Version: bitwardenCacheFormatVersion,
Algorithm: bitwardenCacheAlgorithm,
Nonce: base64.StdEncoding.EncodeToString(nonce),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
}, nil
}
func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) {
if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm {
return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope")
}
nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce)
if err != nil {
return bitwardenCachePlaintext{}, err
}
ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext)
if err != nil {
return bitwardenCachePlaintext{}, err
}
block, err := aes.NewCipher(c.encryptionKey)
if err != nil {
return bitwardenCachePlaintext{}, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return bitwardenCachePlaintext{}, err
}
raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName))
if err != nil {
return bitwardenCachePlaintext{}, err
}
var plaintext bitwardenCachePlaintext
if err := json.Unmarshal(raw, &plaintext); err != nil {
return bitwardenCachePlaintext{}, err
}
return plaintext, nil
}
func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) {
if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 {
return "", false
}
mac := hmac.New(sha256.New, c.entryIDKey)
_, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName)))
return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true
}
func (c *bitwardenCache) memoryKey(secretName, scopedName string) string {
return c.cacheContext(secretName, scopedName)
}
func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte {
return []byte(fmt.Sprintf(
"mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s",
c.serviceName,
strings.TrimSpace(secretName),
strings.TrimSpace(scopedName),
))
}
func (c *bitwardenCache) cacheContext(secretName, scopedName string) string {
return fmt.Sprintf(
"version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s",
bitwardenCacheFormatVersion,
c.serviceName,
strings.TrimSpace(secretName),
strings.TrimSpace(scopedName),
bitwardenCacheContextScope,
)
}
func resolveBitwardenCacheDir(serviceName string) string {
cacheRoot, err := os.UserCacheDir()
if err != nil {
return ""
}
return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName)
}
func bitwardenCacheDisabledByEnv() bool {
raw, ok := os.LookupEnv(bitwardenCacheEnvName)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "0", "false", "no", "off", "disabled":
return true
default:
return false
}
}
```
- [ ] **Step 4: Fix test imports**
Ensure `secretstore/bitwarden_cache_test.go` imports `path/filepath` because `TestBitwardenCacheDiskRoundTripIsEncrypted` uses it.
- [ ] **Step 5: Run cache core tests**
Run:
```bash
go test ./secretstore -run 'TestBitwardenCache'
```
Expected: PASS.
- [ ] **Step 6: Commit**
Run:
```bash
git add secretstore/bitwarden_cache.go secretstore/bitwarden_cache_test.go
git commit -m "feat: add encrypted bitwarden cache core"
```
---
### Task 4: Integrate Cache With Bitwarden Store
**Files:**
- Modify: `secretstore/bitwarden.go`
- Modify: `secretstore/bitwarden_test.go`
- Modify: `secretstore/manifest_open_test.go`
- [ ] **Step 1: Add integration tests**
Add these tests to `secretstore/bitwarden_test.go`:
```go
func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) {
withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
for i := 0; i < 2; i++ {
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
}
if value != "secret-v1" {
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
}
}
if fakeCLI.getItemCalls != 1 {
t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls)
}
}
func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) {
withBitwardenSession(t)
t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0")
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
for i := 0; i < 2; i++ {
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
}
}
if fakeCLI.getItemCalls != 2 {
t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls)
}
}
```
Add this test to `secretstore/manifest_open_test.go`:
```go
func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) {
withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
cacheDisabled := false
store, err := OpenFromManifest(OpenFromManifestOptions{
ServiceName: "email-mcp",
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{
BackendPolicy: string(BackendBitwardenCLI),
BitwardenCache: &cacheDisabled,
},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("OpenFromManifest returned error: %v", err)
}
for i := 0; i < 2; i++ {
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
}
if value != "secret-v1" {
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
}
}
if fakeCLI.getItemCalls != 2 {
t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls)
}
}
```
- [ ] **Step 2: Run integration tests to verify failure**
Run:
```bash
go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable'
```
Expected: FAIL until `bitwardenStore` uses the cache.
- [ ] **Step 3: Add cache field and initialization**
In `secretstore/bitwarden.go`, update `bitwardenStore`:
```go
type bitwardenStore struct {
command string
serviceName string
debug bool
cache *bitwardenCache
}
```
In `newBitwardenStore`, after `EnsureBitwardenSessionEnv`, read the effective session:
```go
session, _ := os.LookupEnv(bitwardenSessionEnvName)
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
```
When constructing the store:
```go
store := &bitwardenStore{
command: command,
serviceName: serviceName,
debug: debugEnabled,
cache: newBitwardenCache(bitwardenCacheOptions{
ServiceName: serviceName,
Session: session,
TTL: defaultBitwardenCacheTTL,
CacheDir: resolveBitwardenCacheDir(serviceName),
Enabled: cacheEnabled,
}),
}
```
Make sure this happens after session restoration, not before. If needed, move the `store := &bitwardenStore{...}` block below the `EnsureBitwardenSessionEnv` call.
- [ ] **Step 4: Use cache in `GetSecret`**
Change `GetSecret`:
```go
func (s *bitwardenStore) GetSecret(name string) (string, error) {
secretName := s.scopedName(name)
if s.cache != nil {
if secret, ok := s.cache.load(name, secretName); ok {
return secret, nil
}
}
_, payload, err := s.findItem(secretName, name)
if err != nil {
return "", err
}
secret, ok := readBitwardenSecret(payload)
if !ok {
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
}
if s.cache != nil {
s.cache.store(name, secretName, secret)
}
return secret, nil
}
```
- [ ] **Step 5: Invalidate on writes and deletes**
After successful create/edit in `SetSecret`, call:
```go
if s.cache != nil {
s.cache.invalidate(name, secretName)
s.cache.store(name, secretName, secret)
}
```
After successful delete or not-found in `DeleteSecret`, call:
```go
if s.cache != nil {
s.cache.invalidate(name, secretName)
}
```
- [ ] **Step 6: Run integration tests**
Run:
```bash
go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable'
```
Expected: PASS.
- [ ] **Step 7: Run all secretstore tests**
Run:
```bash
go test ./secretstore
```
Expected: PASS.
- [ ] **Step 8: Commit**
Run:
```bash
git add secretstore/bitwarden.go secretstore/bitwarden_test.go secretstore/manifest_open_test.go
git commit -m "feat: cache bitwarden secret reads"
```
---
### Task 5: Generated Helper Support
**Files:**
- Modify: `generate/generate.go`
- Modify: `generate/generate_test.go`
- [ ] **Step 1: Write failing generated helper assertion**
In `generate/generate_test.go`, add these expected snippets to the test that checks generated secret store helper content:
```go
"DisableBitwardenCache bool",
"DisableBitwardenCache: options.DisableBitwardenCache,",
```
If there is no focused assertion list, add a test:
```go
func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) {
projectDir := newProject(t, `
binary_name = "demo-mcp"
[secret_store]
backend_policy = "bitwarden-cli"
`)
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("Generate returned error: %v", err)
}
content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
if err != nil {
t.Fatalf("ReadFile generated secretstore: %v", err)
}
text := string(content)
for _, snippet := range []string{
"DisableBitwardenCache bool",
"DisableBitwardenCache: options.DisableBitwardenCache,",
} {
if !strings.Contains(text, snippet) {
t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text)
}
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption
```
Expected: FAIL because generated code lacks `DisableBitwardenCache`.
- [ ] **Step 3: Update generator template**
In `generate/generate.go`, add to generated `SecretStoreOptions`:
```go
DisableBitwardenCache bool
```
Pass to `OpenFromManifestOptions` and `DescribeRuntimeOptions`:
```go
DisableBitwardenCache: options.DisableBitwardenCache,
```
- [ ] **Step 4: Run generator test**
Run:
```bash
go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption
```
Expected: PASS.
- [ ] **Step 5: Commit**
Run:
```bash
git add generate/generate.go generate/generate_test.go
git commit -m "feat: expose bitwarden cache in generated helpers"
```
---
### Task 6: Documentation
**Files:**
- Modify: `docs/manifest.md`
- Modify: `docs/secrets.md`
- [ ] **Step 1: Update manifest docs**
In `docs/manifest.md`, update the `[secret_store]` example:
```toml
[secret_store]
backend_policy = "auto"
# Optionnel: mettre false pour désactiver le cache Bitwarden.
bitwarden_cache = true
```
Add field description:
```markdown
- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire + disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver.
```
- [ ] **Step 2: Update secrets docs**
In `docs/secrets.md`, after the Bitwarden backend section, add this Markdown:
````markdown
## Cache Bitwarden
Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut.
Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache
disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et AES-GCM.
TTL par défaut : 10 minutes.
Pour désactiver le cache dans `mcp.toml` :
```toml
[secret_store]
backend_policy = "bitwarden-cli"
bitwarden_cache = false
```
Pour le désactiver sans modifier le manifeste :
```bash
MCP_FRAMEWORK_BITWARDEN_CACHE=0
```
Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les
secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes
deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut
lire l'environnement ou la mémoire du process pendant l'exécution.
````
- [ ] **Step 3: Run docs grep**
Run:
```bash
rg -n "bitwarden_cache|MCP_FRAMEWORK_BITWARDEN_CACHE|Cache Bitwarden" docs
```
Expected: shows entries in `docs/manifest.md` and `docs/secrets.md`.
- [ ] **Step 4: Commit**
Run:
```bash
git add docs/manifest.md docs/secrets.md
git commit -m "docs: document bitwarden cache controls"
```
---
### Task 7: Final Validation
**Files:**
- No planned code edits unless validation exposes a defect.
- [ ] **Step 1: Run targeted package tests**
Run:
```bash
go test ./manifest ./secretstore ./generate
```
Expected: PASS.
- [ ] **Step 2: Run full test suite**
Run:
```bash
go test ./...
```
Expected: PASS.
- [ ] **Step 3: Inspect git status**
Run:
```bash
git status --short
```
Expected: clean working tree.
- [ ] **Step 4: If validation changed files, commit fixes**
If any fixes were needed:
```bash
git add <changed-files>
git commit -m "fix: stabilize bitwarden cache"
```
If no fixes were needed, do not create an empty commit.
---
## Self-Review Notes
- Spec coverage: manifest default and disable path are covered by Tasks 1, 2, and 6; secure cache core is covered by Task 3; store integration and invalidation are covered by Task 4; generated helper support is covered by Task 5; docs are covered by Task 6; validation is covered by Task 7.
- Red-flag scan: no incomplete markers or open-ended “add tests” steps remain.
- Type consistency: `BitwardenCache` is the manifest field, `DisableBitwardenCache` is the runtime/generated option, `bitwardenCache` is the internal cache type, and `bitwardenCacheEnvName` is the env constant.