think
16px
820px

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. user grants (live since 6f884c1),
    deny-wins (mig 00101), is_owner injection 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_read gains is_owner boolean NOT NULL DEFAULT false with 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)

  1. 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.
  2. Search/embedding indexes: members_only folders filtered per caller (extend the
    existing ACL-filtered list/search predicates); at_rest docs indexed normally; e2ee docs
    NEVER indexed (no text exists server-side). Verify content_text, semantic chunks, and
    Ask-the-Archive retrieval all respect this.
  3. 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.
  4. 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).
  5. Quick Look / filmstrip / recent lists / pins: honor members_only visibility.
  6. MCP tools + share links: read_document/preview_document refuse 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).
  7. 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.
  8. 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_only filtering in list/search/Quick Look; step-up folder gate middleware, letter
    of the requireLetterStepUp pattern — a NEW middleware reading folderID/document→folder,
    NOT a reuse of the docID one); create-dialog presets ("Restricted", "Secret").
  • P2 — at_rest tier: securefolder module 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 main or
    .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 ./..., scratch pgvector/pgvector:pg17 containers for SQL, and
    Playwright against a local vite proxy (VITE_API_TARGET=https://x056.obscura.val.id).
  • Check ls go/migrations | tail immediately 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 with git 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/version matches your commit before testing (co-agents
    redeploy freely).
  • New licence module name securefolder must be added to KnownModules WITHOUT touching
    the signature-canonical byte path (see the module-consolidation incident: alias expansion
    inside normalizeModules darkened every module).
  • Crypto: use filippo.io/age (already a dependency) or Go stdlib + WebCrypto only. NO new
    exotic crypto dependencies without flagging back first.