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.
grimoire-mcp's /mcp endpoint can be added to claude.ai or ChatGPT as a
remote connector — see docs/web-connectors.md for the full design and
config. Summary of the controls, since this is the one surface in the
product explicitly designed to be reached from someone else's cloud:
- Off by default. OAuth activates only when both
GRIMOIRE_PUBLIC_BASEandGRIMOIRE_OAUTH_AUTHORIZE_BASEare set.GRIMOIRE_MCP_TOKEN's static-bearer behaviour is unchanged and unrestricted, exactly as before. - Two listeners, split by trust, not by a check. The consent page
(
/oauth/authorize) that actually grants access is never registered on the public mux at all (internal/oauth'sRegisterPublicvsRegisterPrivate) — a request for it there gets Go's ordinary 404, so there is no conditional to misconfigure into exposing it. It is served only from a second process listener meant to bind loopback or a tailnet address (GRIMOIRE_OAUTH_AUTHORIZE_ADDR), reached in production throughtailscale serveterminating TLS. - Only the vault's owner can approve. The consent page identifies the
caller through the existing Tailscale identity backend
(
internal/identity), gated byGRIMOIRE_OAUTH_ALLOWED_LOGINS— being on the tailnet is not sufficient by itself, only a listed login is trusted — and falls back to the Grimoire admin token typed into the page otherwise. Dynamic Client Registration itself has no access control (that's what lets a client with no prior relationship to this server register at all); this human approval step is what stands between that and a live token. - PKCE (
S256only) is mandatory, redirect URIs must behttpsorlocalhost, and a redirect URI's host must be onGRIMOIRE_OAUTH_ALLOWED_REDIRECTS(default: claude.ai/claude.com, chatgpt.com,*.openai.com, localhost) before DCR will register it — then/oauth/authorizeholds the client to the exact URI it registered. - Tokens are opaque and stored hashed (SHA-256), the same rule
internal/authalready applies to sessions and API keys. Access tokens live 1 hour; refresh tokens live 30 days and rotate on every use, so a refresh token that leaks and is later reused by its legitimate owner invalidates the copy an attacker was holding. - Every token is audience-bound to this server's
/mcp(RFC 8707) — checked at issuance and on every request — so a token cannot be replayed against a different resource. - Scoped access.
notes:read,notes:write,memory,credentials.credentials— the tools that reachinternal/secrets's broker — is never granted to a web connector unless the owner explicitly ticks it on the consent page, regardless of what the client requested. Tool listing and tool calls are both filtered to the token's granted scopes; an out-of-scope call gets403withWWW-Authenticate: error="insufficient_scope". - Revocation.
GRIMOIRE_OAUTH_AUTHORIZE_BASE/admin/oauth, gated byGRIMOIRE_ADMIN_TOKEN, lists every connected client and can revoke all of a client's tokens in one action.
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.
Folder sync (grimoire sync folder, Settings, Sync & backup) treats the cloud
drive as untrusted storage: it can read, delete, roll back or corrupt anything
in the folder.
- Encryption. A key is stretched from the passphrase with Argon2id (t=3, 64 MiB, 4 lanes, 16-byte random salt), split with HKDF-SHA256 into an encryption key and a naming key. Every object is gzip-compressed then sealed with XChaCha20-Poly1305 under a random 24-byte nonce; the associated data binds it to its role and name, so a blob cannot be served as a manifest or one device's manifest moved into another device's directory.
- No names leak. Blobs are named by an HMAC-SHA256 of their content under the naming key; paths, titles and device names live only inside encrypted per-device manifests. What the folder does reveal: the number of devices, the number and approximate sizes of stored objects, and when they change.
- The header (
GrimoireSync/grimoire-sync.json) is the one plaintext file: format version, KDF parameters and salt, and an HMAC check value. The check value lets a device say "wrong passphrase" instead of failing obscurely; it is as resistant to guessing as the KDF makes the passphrase, no more. - The derived key is stored on each device, in
.grimoire/cloudsync/key.json(mode 0600, directory 0700), so sync can run unattended. Anyone who can read that file can read the backup, exactly as anyone who can read the vault can read the notes. It never enters the folder or any synced content, andgrimoire sync offdeletes it.grimoire backuparchives.grimoire/, so treat such an archive as holding the key. - Incoming paths are confined. A device only writes notes (
.md), canvases (.canvas) and files underattachments/, through the sameSafeRawPathsandbox as every other write: a manifest cannot place a file in.grimoire/,plugins/, a hidden directory, or outside the vault. Every blob is authenticated and its content re-hashed against its name before use. - Rollback and damage are not deletions. A missing, truncated, tampered or older manifest or blob is skipped and retried; only an authenticated tombstone deletes a note, and that note goes to the local Trash. Someone holding the passphrase can of course write anything a device would accept.
- A forgotten passphrase is unrecoverable by design: nothing escrowed, no reset.
- 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.