1357 lines
35 KiB
Markdown
1357 lines
35 KiB
Markdown
|
|
# 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.
|