7.7 KiB
Bitwarden Cache Design
Date: 2026-05-02
Context
The secretstore package supports a bitwarden-cli backend. Each secret read can call the Bitwarden CLI several times:
bw list items --search <service>/<secret>bw get item <id>for each candidate item
These calls can take several seconds when commands such as MCP setup, doctor, or generated config resolution read multiple secrets or are run repeatedly.
The framework already requires an unlocked Bitwarden session for this backend and restores a persisted BW_SESSION into the process environment when available. The cache must use that runtime session as the trust root without embedding a static key in the binary or repository.
Goals
- Avoid repeated Bitwarden CLI calls for short-lived and long-running framework processes.
- Keep the feature portable across Windows, macOS, Linux, and WSL.
- Enable the encrypted disk cache by default when
BW_SESSIONis available. - Allow projects and operators to disable the cache.
- Never make cached secrets decryptable with only the installed binary, cache files, and repository content.
Non-Goals
- Protect against an attacker who can read the process memory or environment while the process is running.
- Add an OS keyring dependency for the first implementation.
- Cache non-Bitwarden secret backends.
- Persist decrypted secrets on disk.
Configuration
manifest.SecretStore gains:
[secret_store]
backend_policy = "bitwarden-cli"
bitwarden_cache = true
bitwarden_cache defaults to true when omitted.
The generated helper options continue to flow through OpenFromManifest and DescribeRuntime. The runtime option is represented in secretstore.Options and secretstore.OpenFromManifestOptions so generated packages can still override it programmatically if needed.
An environment variable can force-disable the cache without editing mcp.toml:
MCP_FRAMEWORK_BITWARDEN_CACHE=0
Accepted false values are 0, false, no, off, and disabled, case-insensitive. Any other value leaves the manifest/default behavior in place.
Architecture
The cache is internal to the bitwarden-cli backend.
bitwardenStore.GetSecret(name) resolves secrets in this order:
- In-memory cache.
- Encrypted disk cache, only if a cache key can be derived from the effective
BW_SESSION. - Bitwarden CLI lookup.
After a successful CLI lookup, the secret is written to the memory cache and, when available, the encrypted disk cache.
SetSecret and DeleteSecret update Bitwarden first. After success, they invalidate the cache entry for the affected secret. SetSecret may repopulate the memory and disk cache with the new value after the Bitwarden write succeeds, but it must not expose a value that failed to persist to Bitwarden.
Cache Contents
Memory entries store:
- secret value
- expiration timestamp
Disk entries store encrypted JSON:
{
"version": 1,
"service_name": "graylog-mcp",
"secret_name": "profile/prod/api-token",
"scoped_name": "graylog-mcp/profile/prod/api-token",
"created_at": "2026-05-02T10:00:00Z",
"expires_at": "2026-05-02T10:10:00Z",
"value": "secret"
}
The plaintext exists only before encryption and after decryption in memory.
Key Derivation
Disk cache is enabled only when the effective BW_SESSION is non-empty. The effective session is the value available after EnsureBitwardenSessionEnv, so it can come from either the process environment or the framework-restored session file.
The cache derives dedicated keys with HKDF-SHA256:
master_key = HKDF-SHA256(
input key material: BW_SESSION,
salt: "mcp-framework bitwarden cache salt v1",
info: "mcp-framework bitwarden cache v1"
)
Separate subkeys are derived from master_key:
- encryption key:
info = "mcp-framework bitwarden cache encryption v1" - entry ID key:
info = "mcp-framework bitwarden cache entry id v1"
BW_SESSION is never used directly as an AES key.
Disk Encryption
Disk entries use AES-256-GCM from the Go standard library.
Each write generates a fresh random nonce with crypto/rand. The file format is JSON with metadata needed to decrypt:
{
"version": 1,
"algorithm": "AES-256-GCM",
"nonce": "<base64>",
"ciphertext": "<base64>"
}
Authenticated additional data includes stable, non-secret cache context:
mcp-framework bitwarden cache v1
service=<serviceName>
secret=<secretName>
scoped=<serviceName>/<secretName>
If decryption fails, the entry is treated as a cache miss. The framework may remove the unusable file as best effort.
Entry Identity
Disk file names do not expose secret names or Bitwarden item refs. The file name is:
hex(HMAC-SHA256(entry_id_key, cache_context)) + ".json"
The cache context includes:
- cache format version
- service name
- raw secret name
- scoped Bitwarden item name
- backend scope marker
Because the HMAC key is derived from BW_SESSION, changing or losing BW_SESSION makes existing file names undiscoverable and existing entries undecryptable.
TTL and Invalidation
Default TTL: 10 minutes.
TTL applies to both memory and disk entries. Expired entries are treated as misses and may be deleted best-effort.
The first implementation uses a constant default TTL. It keeps room for a future bitwarden_cache_ttl option, but does not add that option now to keep scope tight.
Invalidation rules:
SetSecretinvalidates the affected entry after the Bitwarden write succeeds.DeleteSecretinvalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent.BW_SESSIONchange implicitly invalidates disk cache through key derivation.bitwarden_cache = falsedisables both memory and disk cache for the backend instance.MCP_FRAMEWORK_BITWARDEN_CACHE=0disables both memory and disk cache.
Storage Location and Permissions
Disk cache path:
os.UserCacheDir()/serviceName/bitwarden-cache
If os.UserCacheDir fails, disk cache is disabled and secret reads continue through memory cache and Bitwarden CLI.
The cache directory is created with 0700 and cache files with 0600 where the platform supports Unix-style permissions. Permission setting errors disable disk cache for that operation rather than failing secret resolution, because Bitwarden remains the source of truth.
Error Handling
Cache failures must not make a healthy Bitwarden backend unusable.
- Memory cache errors are not expected.
- Disk cache read/decrypt/parse errors are treated as misses.
- Disk cache write errors are ignored after optional debug logging.
- Bitwarden CLI errors keep their current behavior and typed error classification.
- Malformed or expired cache entries are never returned.
Tests
Unit tests should cover:
- repeated
GetSecrethits memory and avoids a second CLI item read - reopened store can read from encrypted disk cache without calling
bw get item - disk cache file does not contain the secret value or clear secret name
- cache is disabled by
bitwarden_cache = false - cache is disabled by
MCP_FRAMEWORK_BITWARDEN_CACHE=0 - expired entries are missed and refreshed from Bitwarden
SetSecretinvalidates or refreshes stale cache dataDeleteSecretremoves cached data- missing
BW_SESSIONdisables disk cache - changed
BW_SESSIONcannot decrypt a previous disk entry - manifest parsing preserves the default enabled behavior when the field is omitted
Documentation
Update:
docs/manifest.mdfor[secret_store].bitwarden_cachedocs/secrets.mdfor cache behavior, TTL, disable controls, and threat model- generated/scaffolded
mcp.tomlexamples only if the option should be visible by default
The preferred scaffold output should omit bitwarden_cache because the default is enabled. Documentation should show it as the explicit disable knob.