grimoire stores notes and — uniquely — an encrypted vault of API keys / tokens that
your AI can use but never read. This document states the threat model and the
controls that back it. Security-relevant code: go/internal/crypto,
go/internal/secrets, and the security headers in go/internal/api.
- Secret values at rest — API keys, tokens, MCP credentials.
- Note contents you mark encrypted — sealed on disk, out of index/search/RAG.
- The confidentiality boundary between the AI and raw secrets — the AI can trigger scoped, audited use of a secret but never receives its value.
- The integrity boundary between text you wrote and text somebody else
wrote — connectors put other people's writing into the same corpus an
agent reads from, and that text must not be able to instruct the agent or
overwrite what you told it. See Untrusted content below. This one is
partly a mitigation rather than a guarantee, and is measured rather than
asserted:
benchmarks/injection/.
- Key derivation: Argon2id (64 MiB, t=3, p=4) from your passphrase + a random
per-vault salt. Memory-hard → resistant to GPU/ASIC brute force. Vaults created
before this used PBKDF2-HMAC-SHA256 (240k iters) and still unlock; the KDF is
recorded per vault (
kdffield) so upgrades are transparent. - Encryption: Fernet (AES-128-CBC + HMAC-SHA256) — authenticated; tampering is detected on decrypt.
- The passphrase is never stored. The derived key lives only in process memory and only while unlocked. A verifier token validates the passphrase on unlock without storing it.
- Locked by default.
lock()/ panic-lock drops the key. - Brute-force protection: after 5 failed unlocks the vault enters an exponential-backoff lockout (30s → capped at 1h) — even the correct passphrase is refused during the window.
- Idle auto-lock: the key is dropped after
GRIMOIRE_VAULT_IDLE_LOCKseconds of inactivity (default 900) to shrink the exposure window. - Unattended unlock is opt-in and named.
GRIMOIRE_VAULT_PASSPHRASE_FILEmakes the server unlock itself at startup from a local file, which trades the "only a human holds the passphrase" property for a broker that survives a restart. It is off by default; the file must not be readable by group or other, and the server refuses it if it is. It never initializes a vault — an empty store sealed under a passphrase nobody chose is a worse failure than a locked one. Prefer it to the alternative deployments reach for otherwise: a boot-time script that POSTs the passphrase to/api/vault/unlockputs the passphrase on the HTTP surface and covers only the first start, so any later restart leaves the broker locked and every call failing.
Agents never get raw secrets. They get a grant (a random token, scoped to an exact origin + path prefix, time-boxed) and grimoire brokers the outbound call, injecting the secret into a request header. The response is returned; the secret value is not.
- Scope matching is origin-exact + path-prefix — it parses both URLs and
compares (scheme, host, port) exactly, then checks the path prefix on whole
segments (
/v1does not authorize/v10). This blocks the classic prefix bypass (https://api.github.comdoes not authorizehttps://api.github.com.evil.com), and the inverse where the scope appears in the path of an attacker's URL. - Redirects are re-checked against the scope. Go strips
Authorizationon a cross-host redirect, but the broker injects whatever header the caller named, and anX-Api-Keywould otherwise follow a 302 to another host. - SSRF guard: the target host is resolved and requests to
private / loopback / link-local / reserved / multicast / unspecified addresses
are refused. Cloud-metadata & link-local (
169.254.0.0/16, incl.169.254.169.254) are always refused, even when internal targets are enabled. Self-hosters who legitimately broker to LAN services setGRIMOIRE_BROKER_ALLOW_PRIVATE=1(metadata stays blocked). The check runs at CONNECT time on the address the socket is about to use, not on a hostname resolved earlier, so DNS rebinding does not defeat it — and it covers every redirect hop, since each hop dials again. - Every grant and every broker call is written to an append-only audit log.
- Broker requires the vault unlocked and a valid grant — defense in depth: a leaked grant token alone cannot broker anything.
- Only
http/httpsschemes are permitted (nofile:,gopher:, …).
Connectors (Slack, Confluence, Jira, Drive, GitHub, RSS) and web fetching bring in text that people outside this vault can write. It lands as ordinary markdown notes in the same index, retrieved into the same context as your own notes, and handed to a reader that also holds the credential broker.
Every note carries an origin in its frontmatter, and a binary trust level
derived from it (go/internal/trust). A note with no origin is trusted —
that is every note written before this existed, and every note a person types.
A note whose origin names a connector, a feed or a host is untrusted. A person
may override either way with trust: trusted|untrusted; an unrecognised value
is ignored rather than guessed at, so a typo cannot silently promote a document.
What that enforces:
- Retrieval reports it and can exclude it. Every hit carries
trustandorigin;trusted=1on any content route drops untrusted rows. The filter is applied INSIDE ranking, not to its output — BM25 IDF is computed over the corpus, so an attacker who can post in a connected channel could otherwise shift the ranking of your own notes by flooding it with terms. - The reader is told. Untrusted passages are wrapped in
<<<UNTRUSTED DOCUMENT n — origin: … >>>markers with a one-paragraph rule above them. The fenced text cannot close its own fence (trust.Neutralize), which is the mechanical half; the rest is a prompt, and a prompt is not a boundary.benchmarks/injection/measures what it is worth. - Untrusted text cannot rewrite memory. A fact whose origin is untrusted may
not supersede or retract a fact that came from you. On the model-assisted
path this is enforced by never OFFERING trusted facts as candidates, so no
prompt can name one to overwrite — the same mechanism that protects an
immutablefact.
Not claimed: that a determined injection cannot get through. It can. The controls here reduce a class of it, are measured against a no-intervention baseline, and are not a substitute for the credential broker's scope checks — which are the control that actually stops a stolen instruction from being useful, because an injected URL is not on the grant's origin.
An agent may ASK for a grant it does not have (POST /api/secrets/requests).
Asking issues nothing: a pending request confers no access, and the response
carries no token. A person approves or denies.
- Asking does not require an unlocked vault — that is precisely when nobody is present. Approving does, because approving mints a credential. Denying does not: needing to unlock the credential store to refuse access to it is backwards.
- Asking for a secret that does not exist is accepted rather than refused, so the route cannot be used to enumerate which secrets a vault holds. The name is validated at approval, in front of a person.
GET /api/secrets/requests/{id}is the one read in the product that can return a live grant token. It answers only the grantee the request was created for; an empty or mismatched grantee gets a 404, and the same 404 covers "no such request" so the route is not an oracle for request ids. Listings never carry tokens, and the approval response does not show one to the approver.- A decided request cannot be decided again. An approval that can be replayed is one anybody holding the id can replay, minting a fresh grant per attempt.
Marking a note encrypted seals its body with the vault key. The plaintext never
touches disk, the SQLite index, FTS search, the vector store / RAG, the e-ink
/read surface, or HTML export (encrypted notes are refused there). Duplicating,
trashing, pinning, or tag-renaming an encrypted note operates on the ciphertext
and never needs the key. Editing requires an unlocked vault.
- Security headers on every response: a strict
Content-Security-Policy(script-src 'self',object-src 'none',base-uri 'self'— no inline or external scripts),X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer(so a?token=never leaks via Referer),X-Frame-Options(defaultSAMEORIGIN, override withGRIMOIRE_FRAME_OPTIONS), andCross-Origin-Opener-Policy: same-origin. - XSS: both renderers (
go/internal/render, the PWA'smdToHtml) escape HTML first, then apply a small allowlist of formatting. Wiki-links/images become attributes on escaped text; no user markup reaches the DOM as live HTML. The CSP is defense-in-depth on top. - Auth token (optional,
GRIMOIRE_AUTH_TOKEN): gates every route except/api/health, compared in constant time over SHA-256 digests (fixed width, so the comparison cannot leak the token's length). Accepted asAuthorization: Bearer, as agrimoire_authcookie, or once as?token=— which is then promoted to anHttpOnly; SameSite=Strictcookie so the credential stops travelling in URLs. Empty means open, as it always has.grimoire-mcppresents the same token automatically. - Path traversal: every vault path goes through
safe_path/safe_raw_path, which resolve and confine to the vault root and reject.grimoire. The/api/fileroute and vault export exclude.grimoireso the secret store and index never leave. - No CORS headers are set → browsers enforce same-origin for API calls.
The restricted-read trail (read_audit) is now queried as well as written:
GET /api/admin/reads/anomalies reports bursts — many distinct documents by
one caller in a short sliding window, or a run of denials. Administrator-only,
like the trail itself. It computes on demand and reports; there is no daemon,
no stored threshold state and no notification path, deliberately. Sample
document paths in a result are capped, because a response that quoted every
restricted document a caller touched would be its own disclosure. An empty
result where nothing is recorded is reported as "not applicable", not as
"clear".
Background auto-sync (GRIMOIRE_SYNC_PEER) authenticates to the peer with
GRIMOIRE_SYNC_TOKEN (sent as a Bearer header, so it never appears in a URL/log).
On the receiving side that token is accepted only on the routes a peer
actually calls (/api/sync/manifest, /api/sync/pull, /api/sync/push,
/api/crdt/*): a credential shared with another machine does not also unlock
the secret vault. Peer authentication requires GRIMOIRE_AUTH_TOKEN to be set,
since that is what turns the gate on.
Sync moves plain note files; it never transmits the secret store (.grimoire/ is
excluded from every export/sync path). Direction is last-writer-by-mtime, but no
edit is silently lost — a pull that would overwrite a locally-changed note first
preserves the local copy as a … (conflict …) file, and pushes are conflict-
copied on the peer.
- Front grimoire with your own authenticated reverse proxy (the homelab uses
Authelia + a Tailscale-gated network). The optional
GRIMOIRE_AUTH_TOKENis a second factor, not the primary gate. - Keep
.grimoire/(which holdssecrets.jsonand the index) off any sync/backup that leaves your control unless separately encrypted. - Choose a strong vault passphrase — Argon2id raises the cost of guessing, but a weak passphrase is still the weakest link.
- Leave
GRIMOIRE_FOLLOW_SYMLINKSoff unless you control everything that lands in the vault. With it on, a symlink written into the vault — by a sync client, a shared folder, another user — decides what the server reads and serves.
This is a personal / self-hosted project. If you find an issue, open a private report rather than a public issue.