Two modes, one product
Every Geoff Doc carries an immutable encryption mode chosen at create time:- Shared (default) — server-wrapped DEK. Geoff can decrypt on behalf
of authorised callers. Agents work. Share links SSR at
/s/:code. - Private (E2E) — per-recipient X25519 envelopes on the rack. The Geoff server holds only ciphertext — it cannot read your content. Agents not allowed on these docs. Public share links carry the decryption key in a URL fragment that browsers never send to servers.
TL;DR
- Blobs on the rack (IPFS substrate) are AES-256-GCM ciphertext in both modes. Reading a rack repo directly without the DEK yields random bytes.
- Shared mode: the per-doc DEK is wrapped with a server-held secret
(
DOCS_WRAP_KEY). The wrapped DEK lives in Postgres; the plaintext DEK never touches disk. Server unwraps per-request to decrypt on behalf of authorised callers. - Private mode: the per-doc DEK exists only in ciphertext — wrapped
once per authorised member with that member’s X25519 public key.
The wrapped envelopes live in
KEYS.jsonon the rack. The server has no way to recover the DEK. All encrypt / decrypt happens in the user’s browser or mobile app. - Authentication to read or write a doc goes through the same StackAuth
layer as the rest of Geoff. Permissions are enforced by
lib/docs/permissions.ts.
Storage layout
Shared mode
Private mode
KEYS.json is rewritten in full on every invite or revoke — one rack
commit replaces the previous list.
The rack commit log itself (author, message, timestamp, tree CID) is
plaintext. If you write a sensitive title or commit message, that
metadata is not encrypted. Only the doc.md.enc blob body is.
Keys
1
DOCS_WRAP_KEY (secret)
32 random bytes, base64-encoded, in
.env.local / production env. The
wrap key never leaves the Geoff app process. If it leaks and the
Postgres DB leaks, an attacker can decrypt every doc.2
Per-doc DEK (derived)
Generated at doc-create time via WebCrypto RNG. Wrapped with
DOCS_WRAP_KEY and stored as Doc.dekCiphertext / Doc.dekIv.
Unwrapped per-request, held in memory for the duration of one
decrypt/encrypt, then forgotten.3
Owner delegation (captured JWT)
The doc owner’s StackAuth JWT, encrypted with
DOCS_WRAP_KEY and
stored in Doc.ownerDelegationToken. Used server-side to call
StackNet as the owner when agents edit. Expires when the JWT’s exp
passes — see Agent integration.Trust boundaries
You are trusting:- The Geoff backend process (binary integrity, logs, memory)
DOCS_WRAP_KEYas a secret, equivalent to your database password- Postgres to keep wrapped DEKs private
- StackAuth JWTs for identifying the caller on every request
- The rack’s IPFS layer (blobs are ciphertext)
- Any log or metrics pipeline (no plaintext flows there)
- Client-side code (DEKs never ship to the browser or mobile)
Threat model
- Defended (both modes)
- Defended (private only)
- Not defended
- Explicitly out of scope
- Someone with raw IPFS access pulls
doc.md.enc— they get ciphertext. No DEK in the blob, no DEK inMETA.json. - Compromised share-code URL: revocation is effective on next load
(server checks
revokedAt,expiresAt,maxUses). - Leaked agent JWT:
jti→DocMember.idis checked on every edit. Owner flipsrevokedAt→ subsequent calls return 403 before any LLM spend. - Ciphertext tampering: AES-GCM authentication tag fails; decrypt throws. No partial-recovery attacks.
Roadmap
- Refresh-delegation endpoint — rotate
Doc.ownerDelegationTokenwithout requiring the owner to edit the content. Column already exists. Shared-mode only. - Metadata-at-rest encryption — encrypt commit messages and titles alongside the body. Currently private mode encrypts the body only; title and commit messages are plaintext on the rack.
- Rotating an identity keypair — no first-class flow today. Losing the recovery phrase without a pre-saved device means private docs are unrecoverable. The keypair can be regenerated, but existing envelopes won’t decrypt.
Operator runbook
For rotating keys, revoking leaked tokens, and handling GDPR requests, see the internal runbook atapps/geoff/docs/docs-runbook.md.