Secure Folders — design (approved brainstorm, 2026-08-07)
Status: decisions settled with the product owner; implementation delegated to the
"Secure Folder" conversation. This document is the contract — deviations go back to the
owner, not into code.
Goal: folders whose contents are protected beyond the ACL — hidden from non-members,
entered only after a fresh identity proof, and (as a licensed tier) encrypted so that a
stolen disk, database dump or backup is unreadable.
1. Decisions already made (do not re-litigate)
| Decision | Choice |
|---|---|
| Shape | ONE folder concept + two switches + one encryption tier — not two bespoke folder types (drift risk; see the meterai/sign lesson in the seal-flow audit) |
| Switch A — visibility | inherit (parent ACL decides, today's behavior) | members_only (hidden from listing/search unless explicitly granted) |
| Switch B — entry cost | none | step_up (fresh TOTP or passkey proof) |
| Step-up scope | Per session-window (Samsung Secure Folder model): one successful step-up opens the folder for the existing 15-min StepUpWindow; NOT per-request |
| Encryption tier | Both modes in v1: at_rest (server holds keys, decrypts after step-up) and e2ee (browser-held keys, server never decrypts) — chosen per folder at creation, immutable afterwards |
| E2EE client | Browser-only in v1, honest about the operator caveat (the server ships the JS; a malicious operator can steal the key on next load — the UI copy must say this protects against stolen storage/DB/backups, not against the host operator) |
| Escrow | at_rest: escrow is inherent (the KEK exists server-side) — say so, don't pretend otherwise. e2ee: NO escrow — lose the passphrase, lose the folder; a loud, typed-confirmation warning at creation |
| Retention/JRA | Encrypted folders opt out of retention/disposal/JRA at creation — the owner acknowledges the records-management consequence in the create dialog |
| Licensing | Encryption tier is a separately licensed module (securefolder); the two switches (visibility/step-up) are base-product |
| Password gate | REJECTED — no folder passwords, ever. Step-up (passkey preferred) replaces it |
| No password for admins idea | Superseded by the above; at_rest does not defend against the operator and the copy must never claim it does |
2. What already exists (build on, don't reinvent)
- Deployment-wide blob encryption:
go/internal/platform/blob/cipher.go+EncryptionConfig
(BLOB_ENCRYPTION_KEY_FILE, age X25519, streaming,BackfillEncrypt). Secure folders add a
per-folder key layer, they do not replace this. - Step-up:
auth_step_ups(mig 00164),StepUpValid, 15-min window, TOTP + passkey
(/auth/step-up,/auth/step-up/passkey/*). The folder gate consumes the SAME grant. - ACL: kind-agnostic
(subject_kind, subject_id)incl.usergrants (live since6f884c1),
deny-wins (mig 00101),is_ownerinjection on documents. - Audit chain: append-only, hash-chained (mig 00147) — every unlock/decrypt event lands there.
- Protection floor ratchet (mig 00162): the pattern for "a doc that leaves keeps its floor".
- Egress chokepoint:
egressProtectPDF— where at-rest decryption must NOT bypass marking.
3. Blocker to clear first: folders have no owner
Mig 00101 states it: folders have no owner column. Parity migration, mirroring documents:
ALTER TABLE folders ADD COLUMN owner_id text(nullable; backfill = folder creator where
derivable, else NULL and the admin assigns)folder_acl_readgainsis_owner boolean NOT NULL DEFAULT falsewith the same
owner-injection the document ACL does (owner survives deny-wins)- Owner transfer endpoint (admin, audited) — the "owner leaves" path MUST exist in v1
4. Key hierarchy (at_rest mode)
folder DEK (random 32B, per folder)
wrapped by → deployment KEK (file outside the DB, like BLOB_ENCRYPTION_KEY_FILE; separate key)
document blobs in the folder encrypted with the folder DEK (streaming, same construction as cipher.go)
- Unwrap happens ONLY while the caller holds a live step-up grant AND folder ACL.
- DEK never cached beyond the request; unwrap count per user per hour is metered.
- Every decrypt is written to the audit chain (folder, doc, user, purpose: preview/download/ocr).
- Anomaly alarm: N decrypts by one user in M minutes → notify admins (reuse the notification
events system). This is the control that actually catches insider exfil. - Server capabilities preserved: preview, thumbnails, OCR, search indexing, AI (still gated by
allow_ai_processing), stego/egress marking, seals, retention hooks (though retention is
opted out, legal hold still applies).
Deferred (design for, don't build in v1): sidecar unwrapper
The KEK could live in a dedicated unwrap sidecar (same pattern as stego/extract/embed) so the
app process never holds it. v1 keeps the KEK in the app (file-loaded) but the unwrap MUST go
through ONE function (securefolder.Unwrap(ctx, folderID, purpose)) so relocating it into a
sidecar later is a wiring change, not a refactor.
5. Key hierarchy (e2ee mode)
folder DEK (random 32B, generated IN THE BROWSER)
wrapped by → per-member public keys (X25519; each member's keypair derived from a passphrase
via Argon2id, private key never leaves the browser)
server stores: ciphertext blobs + wrapped DEKs + member public keys. It can never unwrap.
- Upload/download/decrypt in the browser (WebCrypto + age-compatible construction or libsodium
sealed boxes — implementer picks ONE and documents why; must be streaming-capable for PDFs). - Sharing = the owner (in-browser) wraps the DEK for the new member's public key. Requires the
new member to have generated their keypair first (first-open ceremony). - Server-side: NO preview, NO thumbnails, NO OCR/search/AI, NO stego marking, NO seals/meterai
(nothing to sign server-side), NO Office editing. The folder UI must show this plainly, not
discover it through broken buttons — every disabled action carries a "why" tooltip. - Step-up still gates the API surface (fetch of ciphertext + wrapped keys) — defense in depth.
- Copy requirements (verbatim intent, wording may be localized):
- creation: "If every member forgets their passphrase, this folder's contents are
PERMANENTLY unrecoverable. Obscura cannot reset this. Type the folder name to continue." - info panel: "Protects against: stolen server storage, database dumps, backups. Does NOT
protect against: whoever operates the Obscura server, or malware in your browser."
6. The leak surfaces (each needs an explicit task)
- Move OUT of a secure folder → re-encrypt/decrypt accordingly + the document keeps a
protection floor (ratchet doctrine, mig 00162 pattern). Move INTO: encrypt existing versions. - Search/embedding indexes:
members_onlyfolders filtered per caller (extend the
existing ACL-filtered list/search predicates);at_restdocs indexed normally;e2eedocs
NEVER indexed (no text exists server-side). Verifycontent_text, semantic chunks, and
Ask-the-Archive retrieval all respect this. - Notifications/emails: events about documents in
members_only/encrypted folders send
NO document title — generic "a document you have access to" + absolute link. The link lands
on the step-up gate. - Preview cache: keyed by content hash — encrypted docs' rendered pages must be cached
under a folder-scoped key or not at all (the shared-blob leak precedent from templates). - Quick Look / filmstrip / recent lists / pins: honor
members_onlyvisibility. - MCP tools + share links:
read_document/preview_documentrefuse e2ee docs and
step-up-gated docs (an API key holds no step-up); public shares cannot be created on
documents in secure folders (v1: hard refuse). - Dedup: content-addressed blobs are shared across documents — an encrypted upload must
NOT dedup against plaintext copies (different bytes, so naturally distinct) but a plaintext
copy elsewhere reveals the content exists; do not surface "identical to X" hints across a
secure-folder boundary. - Break-glass (at_rest only): two-admin release with typed reason, audited to the chain +
owner notified. e2ee: no break-glass exists — by design.
7. Suggested phasing (implementer may re-slice; keep each phase shippable)
- P1 — foundations: folder owner parity migration + transfer; the two switches
(members_onlyfiltering in list/search/Quick Look; step-up folder gate middleware, letter
of therequireLetterStepUppattern — a NEW middleware readingfolderID/document→folder,
NOT a reuse of the docID one); create-dialog presets ("Restricted", "Secret"). - P2 — at_rest tier:
securefoldermodule licensing; DEK/KEK plumbing on the blob
read/write path (one Unwrap chokepoint); step-up-gated decrypt; audit-chain decrypt events;
volume alarm; move-in/move-out re-encryption; leak surfaces 1–7. - P3 — e2ee tier: browser keypair ceremony; in-browser encrypt/decrypt (streaming);
member wrap/unwrap sharing; the disabled-capability UI; no-escrow warnings; leak surface 6
hard-refusals. - P4 — hardening: break-glass (two-admin), admin visibility dashboard ("N secure folders,
last unlock…"), Cloud (schema-per-tenant) verification, backup-drill note (e2ee folders
restore as ciphertext — expected, document it).
8. Hard constraints for the implementer (non-negotiable)
- Work in a SEPARATE git worktree (
git worktree add ../obscura-securefolder mainor
.claude/worktrees/securefolder). The main checkout is shared by several agents and has
already orphaned commits mid-task once (2026-08-07). Merge back via push/PR, never by
resetting the shared checkout. - NEVER
go test ./...(the test DSN points at the live demo Postgres). Verify with
go build ./... && go vet ./..., scratchpgvector/pgvector:pg17containers for SQL, and
Playwright against a local vite proxy (VITE_API_TARGET=https://x056.obscura.val.id). - Check
ls go/migrations | tailimmediately before numbering a migration (races with
co-agents; 00176 was the last as of this writing). - Never
git add -A/-u; stage explicit paths; verify withgit show --stat HEAD. - Deploy to the demo only via
ssh valbox 'OBSCURA_ENV_FILE=.../deploy/mekari.env bash .../deploy/update.sh --yes';
after deploying, verify/api/v1/versionmatches your commit before testing (co-agents
redeploy freely). - New licence module name
securefoldermust be added toKnownModulesWITHOUT touching
the signature-canonical byte path (see the module-consolidation incident: alias expansion
insidenormalizeModulesdarkened every module). - Crypto: use
filippo.io/age(already a dependency) or Go stdlib + WebCrypto only. NO new
exotic crypto dependencies without flagging back first.