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
newsecure_folder_keystable. The KEK is an age secret key in a file
(SECUREFOLDER_KEK_FILE) — same operational shape asBLOB_ENCRYPTION_KEY_FILE, a
separate key (spec §4). Escrow: added toscripts/escrow-keys.sh. folders.encryption=none | at_rest | e2ee— set at creation, immutable (spec).
e2eeis 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,SetFolderSecurityrefuses 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 verifyStepUpValid
— 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 ofprotection.adminvia the audit-chain-break
notification pattern (SubjectsWithPermission+notify.Notify,jobs.go:449shape),
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 | tailat commit time; nominally 00178):
folders.encryption text NOT NULL DEFAULT 'none' CHECK (encryption IN ('none','at_rest','e2ee')); tablesecure_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:encryptionparam — validates module licensed (securefolder), forces
require_step_up, generates + wraps the folder key in the same tx.SetFolderSecurity
refusal (step-up off while encrypted).SetFolderRetentionrefusal on encrypted chains.- Boot guard in wire.go +
SECUREFOLDER_KEK_FILEconfig + escrow-keys.sh + deploy compose
env/mount (no hardcoded compose default — the Peruri lesson).
T3 — Sealed blob plumbing + dms threading
- kernel
SealedBlobStore+ MinIO impl (PutSealedhashes plaintext while sealing to a
temp file; object keysf/<key_id>/<hash>; quota guard still consulted; dedup within the
namespace only).GetSealed(no deployment-cipher sniff — sealed ns is always sealed). - dms:
AddVersionloads doc+folder BEFORE the Put (today the Put runs first); encrypted
chain ⇒KeyForWrite+PutSealed. Same inAddAttachment.OpenVersionContent/
VersionContentForExtract/OpenAttachmentgain an explicitpurposeand route
throughUnwrap+GetSealedwhen the chain is encrypted (18 call sites updated
mechanically; extract passessystem). deleteBlobsgets 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+previewBasePNGPut +cachedConvertedPDFread 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;
serveMergedOfficeSourceunwraps (purposeoffice— authorized by the office token
that only mints on a step-up-gated route). - Shares (surface 6):
CreateShareLinkhandler refuses docs on step-up-gated OR
encrypted chains (securefolder.share_blocked, not admin-exempt);SharedAccess/
SharedPreview/SharedAskre-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+DocumentTextrefuse
step-up-gated and encrypted docs (securefolder.step_up_unavailable— an API key holds
no step-up), in theaiAllowedslot (handlers_mcp.go:585; preview gets the gate it
currently lacks). - Retention opt-out:
ExplainRetentionreturns no schedule for docs on encrypted
chains;ListDueForDisposition/…Unnotified/ListDueForTransferexclude 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 atwire.go:1891is the one title-carrying emitter.)
T5 — Moves (surface 1)
MoveDocument/BulkMoveacross 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_outwith a completion stamp — thedeleteBlobs+
StampBlobOutcomepattern. Move OUT also ratchetsProtectionFloorto the folder's
default classification when one is set (mig 00162 doctrine).MoveFoldercrossing an encryption boundary: REFUSED in v1
(securefolder.folder_move_blocked) — a subtree re-encrypt is unbounded; documents move
individually.CopyDocumentalready round-trips bytes through the chokepoints (works).
T6 — Web
- Create dialog: 4th preset "Vault" (encrypted, members-only, step-up) — rendered only
withuseHasModule('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
go build ./... && go vet ./...(nevergo test ./...).- Migration smoke (scratch pgvector, fixtures seeded + asserted — extend the P1 script).
- Crypto round-trip against dind MinIO: a scratch harness (
go run ./cmd/...-style or a
scopedgo test ./internal/platform/blob -run Sealedwith explicit local env — never
the full suite): seal→object is an age file undersf/ns; plain copy of same bytes =
different object; unseal round-trips; wrong key fails. - Demo: regen dev licence +
securefolder(recorded recipe, dev key on valbox), generate
+ escrow KEK, compose env, deploy--ref <sha>,/api/v1/versioncheck. - e2e (extend
/tmp/sf-e2e.sh): create Vault folder (module-gated; acknowledgment);
upload doc; MinIO object inspection (age file insf/, 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 showssecurefolder.decryptrows with purposes. - 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).