mcp-framework/secretstore/bitwarden_cache.go

378 lines
9.9 KiB
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"
)
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
}
}