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.
Pick shared when you want automation + the convenience of server-side rendering. Pick private when “the server could in principle read it” is unacceptable.

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.json on 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_KEY as a secret, equivalent to your database password
  • Postgres to keep wrapped DEKs private
  • StackAuth JWTs for identifying the caller on every request
You are NOT trusting:
  • 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

  • Someone with raw IPFS access pulls doc.md.enc — they get ciphertext. No DEK in the blob, no DEK in META.json.
  • Compromised share-code URL: revocation is effective on next load (server checks revokedAt, expiresAt, maxUses).
  • Leaked agent JWT: jtiDocMember.id is checked on every edit. Owner flips revokedAt → 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.ownerDelegationToken without 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 at apps/geoff/docs/docs-runbook.md.