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" ) var bitwardenUserCacheDir = os.UserCacheDir 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 := bitwardenUserCacheDir() 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 } }