docs: design bitwarden cache
This commit is contained in:
parent
afe4c681a1
commit
e99a1c109a
1 changed files with 217 additions and 0 deletions
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal file
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal 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.
|
||||
Loading…
Reference in a new issue