From 85da274772dc27d2e973f5561d77243aaa5082d2 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:00:57 +0200 Subject: [PATCH] feat: add encrypted bitwarden cache core --- secretstore/bitwarden_cache.go | 376 ++++++++++++++++++++++++++++ secretstore/bitwarden_cache_test.go | 131 ++++++++++ 2 files changed, 507 insertions(+) create mode 100644 secretstore/bitwarden_cache.go create mode 100644 secretstore/bitwarden_cache_test.go diff --git a/secretstore/bitwarden_cache.go b/secretstore/bitwarden_cache.go new file mode 100644 index 0000000..a2a9e55 --- /dev/null +++ b/secretstore/bitwarden_cache.go @@ -0,0 +1,376 @@ +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 + } +} diff --git a/secretstore/bitwarden_cache_test.go b/secretstore/bitwarden_cache_test.go new file mode 100644 index 0000000..5901229 --- /dev/null +++ b/secretstore/bitwarden_cache_test.go @@ -0,0 +1,131 @@ +package secretstore + +import ( + "bytes" + "os" + "path/filepath" + "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) + } +}