docs: design bitwarden cache

This commit is contained in:
thibaud-lclr 2026-05-02 14:46:19 +02:00
parent afe4c681a1
commit e99a1c109a

View file

@ -0,0 +1,217 @@
# 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:
```toml
[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`:
```text
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:
```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:
```text
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:
```json
{
"version": 1,
"algorithm": "AES-256-GCM",
"nonce": "<base64>",
"ciphertext": "<base64>"
}
```
Authenticated additional data includes stable, non-secret cache context:
```text
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:
```text
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:
```text
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.