think
16px
820px

Secure Folders — P2 implementation plan (at_rest encryption tier)

Spec (contract): docs/superpowers/specs/2026-08-07-secure-folders-design.md §4, §6, §7-P2.
Builds on: P1 (landed eca01ee) — switches, owner parity, requireFolderStepUp,
hideMembersOnlyFolder, positional chain-gate SQL (FolderStepUpGate).
Owner go-ahead: given in the panel 2026-08-07 ("Continue P2").

0. Design commitments (the shape, before the tasks)

0.1 Key model — mirrors the deployment cipher, one level down

  • Each encrypted folder gets its own age X25519 identity ("folder key"), generated at
    folder creation, stored wrapped (age-encrypted to the securefolder KEK recipient) in a
    new secure_folder_keys table. The KEK is an age secret key in a file
    (SECUREFOLDER_KEK_FILE) — same operational shape as BLOB_ENCRYPTION_KEY_FILE, a
    separate key (spec §4). Escrow: added to scripts/escrow-keys.sh.
  • folders.encryption = none | at_rest | e2ee — set at creation, immutable (spec).
    e2ee is a reserved value; P2 refuses it at create.
  • Positional, like step-up: everything under an encrypted folder is encrypted with the
    nearest encrypted ancestor's key (subfolders don't get own keys). Chain resolution =
    FolderEncryptionGate / DocumentEncryptionGate, the recursive-CTE twins of P1's
    step-up gates.
  • Encryption implies require_step_up = true (spec: unwrap only with a live step-up
    grant); create forces it on, SetFolderSecurity refuses turning it off while the chain
    is encrypted. Visibility stays the owner's choice.
  • Boot guard: encrypted folders exist + KEK missing/unparseable ⇒ refuse boot
    (the Peruri prod-readiness doctrine: an invalid-but-bootable combination is invisible).

0.2 Storage — plaintext hash, sealed object, distinct namespace

The store already hashes PLAINTEXT and stores ciphertext under that hash (deployment
cipher). Folder-sealed blobs keep that: content_hash stays the plaintext sha256
(all 13 refcount columns, esign covers_current hash compares, template zero-copy seeding
stay coherent) — but the OBJECT lives under sf/<key_id>/<hash> (inside the tenant
prefix). Consequences, all intended:
- No dedup across the secure boundary (leak surface 7): a plaintext copy elsewhere is a
different object key by construction.
- Deployment cipher is NOT layered on top (the folder key already covers at-rest).
- kernel seam gains a SealedBlobStore interface (PutSealed/GetSealed/DeleteSealed with a
key-namespace + streaming seal/open callbacks, so kernel never imports age); MinIOStore
implements it beside the existing methods.
- Known residue (documented, fix = P4): UnreferencedContentHashes is namespace-blind.
A hash referenced by BOTH a plain and a sealed doc can strand one object as an orphan
when the other side is destroyed (ciphertext orphan: harmless; plaintext orphan while
only the sealed doc remains: deployment-encrypted, swept later). P4 makes the refcount
namespace-aware + adds an orphan sweep.

0.3 The Unwrap chokepoint — go/internal/securefolder

KeyForWrite(ctx, gateFolderID) (*Key, error)             // recipient + ns; sealing needs no step-up
Unwrap(ctx, gateFolderID, docID, purpose) (*Key, error)  // THE chokepoint (spec §4 deferred-sidecar rule)
  • Purposes: preview download extract office seal copy move zip (user) + system
    (the detached extract goroutine has no principal). User purposes verify StepUpValid
    — FAIL CLOSED
    ; system purposes pass but are still audited.
  • Every Unwrap → audit-chain event securefolder.decrypt {folder, doc, user, purpose}.
  • Volume alarm: in-memory sliding window per user; over threshold (default 50 unwraps /
    10 min, env-tunable) ⇒ notify holders of protection.admin via the audit-chain-break
    notification pattern (SubjectsWithPermission + notify.Notify, jobs.go:449 shape),
    once per user per window. This is the control that catches insider exfil.
  • Keys are never cached beyond the call. Relocating the KEK into a sidecar later = swapping
    this service's internals (the spec's deferred design).

0.4 What at_rest deliberately does NOT change (spec §4 "capabilities preserved")

Preview, OCR/extract, search indexing, AI enrichment, sealing, egress/stego marking all
keep working — the server unwraps for them. Honest-copy caveat to flag to the owner:
content_text + semantic chunks remain plaintext rows in Postgres (spec surface 2 says
"at_rest docs indexed normally"), so a DB dump exposes extracted TEXT of at_rest docs even
though the FILES are unreadable. The info-panel copy must say "protects the stored files;
extracted text remains indexed for search". If the owner wants text-at-rest too, that is a
scope change (P4 candidate), not P2.

1. Tasks

T1 — Migration + licence module

  • Mig (number = ls go/migrations | tail at commit time; nominally 00178):
    folders.encryption text NOT NULL DEFAULT 'none' CHECK (encryption IN ('none','at_rest','e2ee')); table secure_folder_keys (folder_id uuid PK REFERENCES folders, key_id uuid UNIQUE, wrapped_key bytea, created_at, created_by text); goose Down.
  • KnownModules += securefolder (config.go:659 — one token; normalizeModules
    canonical bytes UNTOUCHED; no alias). licensegen inherits via the list. Web mirror
    LicensingTab.tsx:33.
  • Domain: Folder.Encryption, folderColumns/scanFolder/InsertFolder, constants +
    IsEncrypted().

T2 — securefolder service + wiring

  • Package go/internal/securefolder: KEK file load; CreateFolderKey (invoked inside
    CreateFolder's tx); KeyForWrite; Unwrap (step-up check via injected
    authapp.StepUpValid, audit sink, alarm, purpose enum); chain gates
    FolderEncryptionGate/DocumentEncryptionGate (SQL in dms adapters, mirroring P1).
  • CreateFolder: encryption param — validates module licensed (securefolder), forces
    require_step_up, generates + wraps the folder key in the same tx. SetFolderSecurity
    refusal (step-up off while encrypted). SetFolderRetention refusal on encrypted chains.
  • Boot guard in wire.go + SECUREFOLDER_KEK_FILE config + escrow-keys.sh + deploy compose
    env/mount (no hardcoded compose default — the Peruri lesson).

T3 — Sealed blob plumbing + dms threading

  • kernel SealedBlobStore + MinIO impl (PutSealed hashes plaintext while sealing to a
    temp file; object key sf/<key_id>/<hash>; quota guard still consulted; dedup within the
    namespace only). GetSealed (no deployment-cipher sniff — sealed ns is always sealed).
  • dms: AddVersion loads doc+folder BEFORE the Put (today the Put runs first); encrypted
    chain ⇒ KeyForWrite + PutSealed. Same in AddAttachment. OpenVersionContent /
    VersionContentForExtract / OpenAttachment gain an explicit purpose and route
    through Unwrap + GetSealed when the chain is encrypted (18 call sites updated
    mechanically; extract passes system).
  • deleteBlobs gets the namespace threaded (DestroyDocument collects the chain's
    key-ns in-tx, before rows die — the highest-risk gap from the survey). Preview-cache
    purge rows for those hashes handled as today.

T4 — Derived-content + egress surfaces (leak surfaces 2–7)

  • Preview cache (surface 4): encrypted docs are never cached persistently — skip
    storeConvertedPDF + previewBasePNG Put + cachedConvertedPDF read for docs on
    encrypted chains (the 15-min in-RAM preview session still applies; documented). The
    transient merged-office blob is staged SEALED under the folder ns instead of plain;
    serveMergedOfficeSource unwraps (purpose office — authorized by the office token
    that only mints on a step-up-gated route).
  • Shares (surface 6): CreateShareLink handler refuses docs on step-up-gated OR
    encrypted chains (securefolder.share_blocked, not admin-exempt); SharedAccess/
    SharedPreview/SharedAsk re-check at access time (a doc MOVED into a secure folder
    may have live links — they go dark).
  • MCP (surface 6): read_document + preview_document + DocumentText refuse
    step-up-gated and encrypted docs (securefolder.step_up_unavailable — an API key holds
    no step-up), in the aiAllowed slot (handlers_mcp.go:585; preview gets the gate it
    currently lacks).
  • Retention opt-out: ExplainRetention returns no schedule for docs on encrypted
    chains; ListDueForDisposition/…Unnotified/ListDueForTransfer exclude them
    (materialized-path prefix predicate); folder retention PUT refused (T2). Legal hold
    untouched
    (spec).
  • Notifications (surface 3) for members_only/encrypted folders: events about their docs
    send generic "a document you have access to" (no title) + absolute link landing on the
    gate. (The version_added fan-out at wire.go:1891 is the one title-carrying emitter.)

T5 — Moves (surface 1)

  • MoveDocument/BulkMove across an encryption boundary: metadata tx first (records the
    old/new key-ns), then out-of-band re-encryption of ALL versions + attachments under
    context.WithoutCancel (read old ns → write new ns → delete old objects), audited
    securefolder.moved_in/moved_out with a completion stamp — the deleteBlobs +
    StampBlobOutcome pattern. Move OUT also ratchets ProtectionFloor to the folder's
    default classification when one is set (mig 00162 doctrine).
  • MoveFolder crossing an encryption boundary: REFUSED in v1
    (securefolder.folder_move_blocked) — a subtree re-encrypt is unbounded; documents move
    individually. CopyDocument already round-trips bytes through the chokepoints (works).

T6 — Web

  • Create dialog: 4th preset "Vault" (encrypted, members-only, step-up) — rendered only
    with useHasModule('securefolder'); selecting it requires ticking an acknowledgment:
    retention/JRA opt-out + the honesty line ("protects stored files against stolen
    storage/DB/backups; the server operator can still read them; extracted text stays
    searchable").
  • Security tab: read-only "Encrypted at rest" row (immutable by design); step-up toggle
    disabled+explained while encrypted. Card badge (distinct icon). Licensing tab mirror.
  • Share dialog + MCP-facing errors surface the refusal copy.

T7 — Verification

  1. go build ./... && go vet ./... (never go test ./...).
  2. Migration smoke (scratch pgvector, fixtures seeded + asserted — extend the P1 script).
  3. Crypto round-trip against dind MinIO: a scratch harness (go run ./cmd/...-style or a
    scoped go test ./internal/platform/blob -run Sealed with explicit local env — never
    the full suite): seal→object is an age file under sf/ ns; plain copy of same bytes =
    different object; unseal round-trips; wrong key fails.
  4. Demo: regen dev licence + securefolder (recorded recipe, dev key on valbox), generate
    + escrow KEK, compose env, deploy --ref <sha>, /api/v1/version check.
  5. e2e (extend /tmp/sf-e2e.sh): create Vault folder (module-gated; acknowledgment);
    upload doc; MinIO object inspection (age file in sf/, absent from plain ns);
    step-up → download round-trips; search finds it for a member (content_text indexed);
    share create refused (also for admin); MCP read refused; retention explain empty +
    disposition list excludes; move out re-encrypts + object relocates + floor ratchets;
    move folder across boundary refused; alarm fires at a lowered env threshold; audit
    chain shows securefolder.decrypt rows with purposes.
  6. UI smoke: Vault preset + acknowledgment gate + badge screenshots.

2. Sequencing

T1 → T2 → T3 (each its own commit, build+vet green) → T4, T5 parallelizable → T6 → T7.
Rebase before every push (main moved under this branch twice already).

3. Risks / open flags

  • content_text honesty caveat (0.4) — flagged to owner; copy handles it in P2.
  • Namespace-blind refcount residue (0.2) — accepted, P4.
  • Office doc-server fetches decrypted bytes over the internal network (same posture as the
    deployment cipher today; noted, not new exposure).
  • Live share links to docs later moved into secure folders go dark by design (surface 6).
  • MoveFolder boundary refusal is a v1 simplification — owner may later want async subtree
    re-encryption (P4 candidate).