feat: add encrypted bitwarden cache core
This commit is contained in:
parent
1a44a2ea35
commit
85da274772
2 changed files with 507 additions and 0 deletions
376
secretstore/bitwarden_cache.go
Normal file
376
secretstore/bitwarden_cache.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
131
secretstore/bitwarden_cache_test.go
Normal file
131
secretstore/bitwarden_cache_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue