This document covers how Grimoire protects sensitive data, known gaps, and planned improvements. It is a living document — update it when security-relevant code changes.
Grimoire is a single-user daemon holding decrypted vault keys in memory. The trust boundary is the Unix socket — same model as ssh-agent. We defend against:
- Other users on the same machine: socket permissions + UID validation
- Swap/core dump exposure: mlockall + PR_SET_DUMPABLE
- Brute force: master password backoff, PIN attempt limits
- Session hijacking: session timer + re-verification
We do not currently defend against:
- Root access: root can read process memory, attach debugger, etc.
- Same-user attackers: another process running as the same user can connect to the socket (this is by design, same as ssh-agent)
- Physical access with unlocked session: no screen-lock integration yet
- Linux:
mlockall(MCL_CURRENT | MCL_FUTURE)at service startup — prevents pages from being swapped to disk.prctl(PR_SET_DUMPABLE, 0)— prevents core dumps and ptrace from non-root. Both are fatal on failure — the service refuses to start without memory protection. - macOS:
ptrace(PT_DENY_ATTACH)— prevents debugger attachment (same mechanism used by Apple's security daemon). Fatal on failure.
bitwarden-cryptousesZeroizingAllocatorandKeyStoreinternally for key material- We never extract raw keys from the SDK — all crypto ops go through
PasswordManagerClient
All password and PIN fields use zeroize::Zeroizing<String> — memory is zeroed on drop. This covers:
LoginParams.passwordandUnlockParams.passwordin protocol typesLoginCredentials.passwordin the SDK wrapperSession.pinheld in service state (Option<Zeroizing<String>>)SetPinParams.pinin protocol typesPromptResponse.credentialfrom prompt subprocess- Local variables from
rpassword::prompt_password()in the CLI ServiceState.unlock()andverify_password()accept&Zeroizing<String>AuthClient.unlock()andverify_password()accept&Zeroizing<String>- SSH Ed25519 raw key bytes zeroized after signing (
ssh.rs)
The SDK uses ZeroizingAllocator internally for key material. Between Grimoire's zeroization of passwords and the SDK's zeroization of keys, the sensitive-data lifecycle is covered.
- No macOS swap protection. macOS has no
mlockallequivalent.PT_DENY_ATTACHprevents debugger attachment but does not prevent pages from being swapped to disk. macOS processes may swap sensitive pages. - SSH private key zeroization is partial. Ed25519 raw key bytes are zeroized after signing, but
ssh_key::PrivateKeyanded25519_dalek::SigningKeydo not implementZeroizein their current versions. Thersa::RsaPrivateKeyis consumed by the signing key constructor (not leaked). Seegrimoire-sdk/src/ssh.rs. - Password
Stringcopy at SDK boundary.unlock()andverify_password()accept&Zeroizing<String>, but the SDK'sInitUserCryptoRequestrequires a plainString. The copy is documented and minimized but not zeroized by us — the SDK'sZeroizingAllocatorhandles it.
- Investigate macOS
mlockper-page support for key material - Consider
seccompfiltering on Linux to restrict syscalls
- Runtime directory:
$XDG_RUNTIME_DIR/grimoire/(mode0700) or/tmp/grimoire-<id>/fallback - Socket file: mode
0600(owner read/write only) - Stale sockets removed before binding
- Main socket (Linux/macOS):
SO_PEERCRED/getpeereid()check on every connection — peer UID must match service UID. Connections from other users are rejected. - SSH agent socket: Same UID peer verification inside
SshAgentHandler::new_session(). Rejected sessions return empty identities and refuse signing.
- Every connection performs an X25519 key exchange followed by ChaCha20-Poly1305 AEAD encryption
- Wire format per message:
[4-byte length][8-byte nonce counter][ciphertext + 16-byte tag] - Ephemeral keypairs generated per connection — no key reuse across sessions
- Nonce counter prevents replay within a connection
- Socket permissions (
0600+ UID check) remain the primary trust boundary; encryption provides defense-in-depth against local eavesdropping or socket path attacks
- Max 64 concurrent connections — enforced by a
tokio::sync::Semaphore. Excess connections are rejected immediately. - 10-second handshake timeout — the X25519 key exchange must complete within 10 seconds or the connection is dropped.
- 60-second idle timeout — clients that don't send a message within 60 seconds are disconnected.
- 1 MiB message size limit — prevents memory exhaustion from oversized payloads.
- Fallback socket path uses
/tmp/grimoire-<uid>/(real UID vialibc::getuid()).$XDG_RUNTIME_DIRis preferred when available (user-owned,0700, managed by systemd).
- Exponential backoff on failed login/unlock: 0s, 1s, 2s, 4s, 8s, 16s, 30s (capped)
- Enforced server-side — the service rejects attempts before the backoff window expires (error code 1009)
- Counter resets on successful authentication
- Persisted to disk (
~/.local/share/grimoire/backoff.json, mode0600) — service restart does not reset the counter. Prevents restart-based brute force bypass.
- 3 attempts, no delay between them
- After 3 failures: vault locks automatically (keys scrubbed, need master password)
- PIN stored as
Option<Zeroizing<String>>in service memory, never on disk - Constant-time comparison via XOR fold — leaks length due to early return on length mismatch. Acceptable for short PINs (4-6 digits).
- After unlock, a session timer starts (default 300s)
- Expired session gates vault operations behind re-verification
- Re-verify order: biometric → PIN → master password fallback
- Session is per-service, not per-client — all connected clients share one session
- Service looks for
grimoire-prompt-{platform}thengrimoire-promptonly adjacent tocurrent_exe() - No PATH fallback — if the prompt binary is not found next to the service, the service returns a clear error. This prevents PATH-based interception of master passwords.
- Install both binaries in the same directory
- macOS biometric: Uses inline Swift via
swift -ewith thereasonparameter interpolated into a string literal. Thereasonis currently hardcoded in our code, but the interpolation does not escape special characters — a latent injection vector ifreasonever comes from untrusted input. - macOS password dialog: Uses
osascript— the prompt message is interpolated into AppleScript. Same escaping concern as biometric. - Linux GUI: Uses
zenity/kdialogwith arguments — lower injection risk since arguments are not shell-interpreted. - Terminal: Uses
rpassword— no injection risk.
- Prompt agent writes one JSON line to stdout, exits with 0/1/2
- Service reads and parses the JSON response
- No signature or integrity check on the prompt binary's output — service trusts whatever is in stdout
The SDK does not expose an explicit "clear keys" operation. Lock is implemented by:
- Dropping the
GrimoireClient(which drops the innerPasswordManagerClient) - Creating a fresh
GrimoireClientfor the same server URL - Preserving
LoginStateso re-unlock doesn't require re-login
- Key erasure depends on SDK Drop impl. We assume
bitwarden-crypto'sKeyStorezeros keys on drop (it usesZeroizingAllocator), but this is not verified at the Grimoire layer. - LoginState persists across lock. The
LoginStatecontainsMasterPasswordUnlockData(encrypted user key, KDF params) andWrappedAccountCryptographicState(encrypted private key). These are encrypted — holding them in memory while locked is equivalent to what the official Bitwarden client does.
The SDK requires the consuming application to provide repositories for certain state types. We register in-memory HashMap-backed repositories (grimoire-sdk/src/state.rs) for:
LocalUserDataKeyState— holds the user's data key wrapped by the user key (EncString)EphemeralPinEnvelopeState— PIN envelope for ephemeral PIN unlockUserKeyState— decrypted user key (as base64)Cipher— encrypted cipher objects from syncFolder— encrypted folder objects from sync
Security layering:
LocalUserDataKeyStateandEphemeralPinEnvelopeStatehold encrypted (wrapped) values — the actual decryption keys never leave the SDK'sKeyStorewhich usesZeroizingAllocatorUserKeyStateholds a decrypted user key as base64 in a plainStringinside ourHashMap— this is the most sensitive item and is not zeroized on dropCipherandFolderhold server-encrypted objects that require the user key to decrypt
Future improvement: Replace the HashMap<String, V> backing with a zeroizing-on-drop container, particularly for UserKeyState. Consider whether Cipher/Folder repositories should be backed by the SQLite database (SDK-managed) instead of in-memory, to support offline access and reduce memory footprint for large vaults.
After successful login, the service saves encrypted credentials to ~/.local/share/grimoire/login.json (mode 0600) so subsequent service restarts only require grimoire unlock, not a full re-login.
What's persisted (all encrypted or non-sensitive):
- Email and server URL
- User ID (from JWT)
- KDF configuration (type, iterations, memory, parallelism)
- Master-key-wrapped user key (
EncString— encrypted with master password) - Encrypted private key (
EncString— encrypted with user key)
What's NOT persisted:
- Master password (never stored)
- Decrypted user key or private key
- Session tokens (re-obtained on each unlock via the SDK)
Lifecycle:
- Created on
grimoire login - Read on service startup → starts in
Lockedstate if present - Deleted on
grimoire logout
Security notes:
- The persisted file contains the same encrypted material that the Bitwarden server returns on login — equivalent to what official Bitwarden clients cache locally
- File permissions are set to
0600immediately after write - An attacker with read access to this file still needs the master password to derive keys
- Security parameters (auto-lock timeout, approval duration, PIN max attempts, approval scope, approval requirement) are hardcoded constants — not configurable via config file. This prevents config-based downgrade attacks.
- Only operational settings are configurable: server URL, prompt method (auto/gui/terminal/none), SSH agent enabled/disabled.
- Config file at
~/.config/grimoire/config.toml— refuses to start if group/world-writable (mode & 0o022). While security parameters are hardcoded,server.urlis security-relevant (malicious URL redirects password hash). No fallback, no override. - Config is loaded once at startup — runtime changes require restart.
- Git dependency pinned to a specific revision — no published crate, no semver guarantees
- Uses pre-release RustCrypto crates (
argon2 =0.6.0-rc.2, etc.) - Transitive deps must be manually pinned after updates (see
UPGRADING.md) digest 0.11.1is yanked on crates.io but required for compatibility
- RUSTSEC-2023-0071 (rsa 0.9.x and 0.10.0-rc.x) — Marvin Attack timing sidechannel. No fixed version available. Pulled in transitively by the SDK via
bitwarden-cryptoandssh-key. Our RSA usage is SSH signing over a local Unix socket where the timing attack is not exploitable. Ignored incargo audit. - RUSTSEC-2026-0049 (rustls-webpki 0.103.x) — CRL Distribution Point matching logic bug. No fixed version available. Transitive dep from the SDK's
reqwestchain. Ignored incargo audit. - Yanked crates:
digest 0.11.1andcrypto-bigint 0.7.1are yanked but required by the SDK's pre-release RustCrypto stack. Builds work from the committedCargo.lock; a cleancargo updatewould fail to resolve these.
cargo auditruns on every push/PR with explicit--ignoreflags for the unfixable advisories above- All advisories are re-evaluated on each SDK revision bump
rpassword— terminal password input, well-maintainedtokio— async runtime,features = ["full"](narrowing to explicit features is a medium-term goal)serde_json— JSON parsing, no known vulnerabilitieslibc— FFI for mlockall/prctl/ptrace/getuid/getsid, Unix only
- Every release tarball is SHA256-checksummed — checksums file uploaded as a release asset
- Checksums file is signed with cosign keyless (GitHub OIDC identity) — verifiers confirm the signature came from the release workflow, not just someone with a key
- Install script (
contrib/install.sh) verifies SHA256 before extracting
- Release workflow re-runs the full CI gate (fmt, clippy, tests) before building —
needs: [gate]dependency on all build jobs - No release ships without passing checks, regardless of how the tag was created
flake.lockis committed — all input revisions are pinned, changes visible in PR diffs- NixOS module only exposes operational settings (server URL, prompt method, SSH agent toggle) — security parameters are hardcoded constants in the binary
- systemd service includes sandboxing directives (
NoNewPrivileges,MemoryDenyWriteExecute, etc.)
- Branch protection on
main— workflow file changes require PR review - Tag protection rules — prevent deletion/overwrite of
v*tags
| Priority | Issue | Status |
|---|---|---|
Fixed — all password/PIN fields use Zeroizing<String> |
||
Fixed — escape_swift() and escape_applescript() sanitize all interpolated strings |
||
Fixed — UID check now uses #[cfg(unix)] (tokio's peer_cred() works on both Linux and macOS) |
||
Fixed — uses libc::getuid() on Unix |
||
Fixed — PATH fallback removed, only checks adjacent to current_exe() |
||
Fixed — persisted to backoff.json |
||
Fixed — touch() called in dispatch() for every session-guarded operation |
||
| Fixed — 64 max connections, 10s handshake timeout, 60s idle timeout, 1 MiB message limit | ||
| Fixed — all memory hardening failures are fatal, no fallback | ||
Fixed — UID peer verification added in SshAgentHandler::new_session() |
||
Fixed — cargo audit runs on every push/PR |
||
| Fixed — refuses to start with group/world-writable config | ||
| Fixed — PBKDF2 max 2M iter, Argon2 max 4096 MiB / 16 threads | ||
| Medium | Password String copy at SDK boundary |
Mitigated — Zeroizing<String> passed to SDK boundary, but SDK requires plain String internally |
| Medium | SSH key zeroization partial | Mitigated — Ed25519 raw bytes zeroized, but PrivateKey/SigningKey types lack Zeroize impl |
| Medium | UserKeyState holds decrypted key in plain HashMap |
Open — SDK-managed state, needs upstream zeroizing container |
| Medium | No macOS swap protection | Open — PT_DENY_ATTACH prevents debugger but no mlockall equivalent |
| Medium | Sync holds read lock during HTTP call | Open — blocks state mutations during server requests |
| Medium | login.json has no integrity protection |
Open — same-user attacker can redirect server_url |
| Medium | CI actions not pinned to commit SHAs | Open — tag-based references are a supply chain risk |
| Medium | tokio uses features = ["full"] |
Open — wider attack surface than needed |
| Low | PIN length leaked via timing | Accepted — acceptable for 4-6 digit PINs per design decision |
| Medium | Offline vault cache on disk (vault_cache.bin) |
Mitigated — envelope-encrypted with platform-bound CEK (macOS Keychain / Linux Secret Service). Fallback (no credential store) relies on master password KDF only. See ADR 016. |
| Low | macOS CEK lacks biometric gate | Open — Keychain item stored without kSecAccessControlUserPresence; same-user processes can read silently. Device-binding still applies. |
| Low | Background sync cannot update cache HMAC | Open — cache reflects vault state at last unlock; background sync detects changes but cannot re-sign without password hash |
| Low | Error messages leak vault item names/counts | Open — resolve_single_ref includes names in errors |
| Low | RUSTSEC-2023-0071 (RSA Marvin Attack) | Accepted — SDK transitive dep, no fix available, not exploitable over local socket |
| Low | RUSTSEC-2026-0049 (rustls-webpki CRL) | Accepted — SDK transitive dep, no fix available |