mcp-framework/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md

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_SESSION is 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:

  1. In-memory cache.
  2. Encrypted disk cache, only if a cache key can be derived from the effective BW_SESSION.
  3. 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:

  • SetSecret invalidates the affected entry after the Bitwarden write succeeds.
  • DeleteSecret invalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent.
  • BW_SESSION change implicitly invalidates disk cache through key derivation.
  • bitwarden_cache = false disables both memory and disk cache for the backend instance.
  • MCP_FRAMEWORK_BITWARDEN_CACHE=0 disables 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 GetSecret hits 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
  • SetSecret invalidates or refreshes stale cache data
  • DeleteSecret removes cached data
  • missing BW_SESSION disables disk cache
  • changed BW_SESSION cannot decrypt a previous disk entry
  • manifest parsing preserves the default enabled behavior when the field is omitted

Documentation

Update:

  • docs/manifest.md for [secret_store].bitwarden_cache
  • docs/secrets.md for cache behavior, TTL, disable controls, and threat model
  • generated/scaffolded mcp.toml examples 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.