think
16px
820px

Secure Folders — P3 plan (Private Vault: passkey-sealed, server-blind)

Owner decisions taken 2026-08-07 (this conversation), superseding the spec where noted:
- The e2ee tier ships as a fifth preset, "Private Vault" — a SEPARATE tier above the
at_rest Vault, not the default for encrypted folders. Rationale accepted: e2ee costs
preview/search/AI/signing, and a default that can permanently eat a folder is a support
disaster. At_rest answers most clients' real threat model with every feature intact.
- Inside the SAME securefolder module (no second SKU). Mode is chosen at creation.
- Unlock is passkey-first (WebAuthn PRF), not passphrase-first. This is the change from
spec §5, which assumed "each member's keypair derived from a passphrase via Argon2id".
Passphrase becomes the FALLBACK wrap for browsers/authenticators without PRF.
- Creating any secure folder — including this one — requires folder.secure (shipped
with P2's RBAC slice, 18c9fac).

1. Why passkey-PRF, and what it does not fix

Why. The authenticator derives a stable 32-byte secret from the credential, evaluated
INSIDE the authenticator and gated by the platform biometric (Touch ID / Windows Hello /
Android). The browser unwraps the folder key with it. Nothing to remember, phishing-
resistant, and the biometric becomes a real cryptographic step rather than a middleware
check. Prior art: Bitwarden's passkey-encrypted vaults.

What it does NOT fix, and the copy must keep saying so: the server ships the JavaScript.
A hostile operator can serve poisoned JS and capture the key at the next unlock. This
defends passive compromise — stolen storage, DB dumps, backups, a curious DBA — not an
actively malicious host. (Spec §5 copy requirement stands verbatim.)

Availability is not universal. PRF requires a supporting browser AND authenticator
(Chrome/Edge, Safari 18+, synced iCloud/Google passkeys, newer security keys). The
passphrase fallback is therefore not optional garnish — it is what keeps the tier usable.

2. Key model

folder DEK (random 32B, generated IN THE BROWSER, never sent to the server)
  wrapped once per member per credential:
    ├─ passkey wrap:     AES-KW/HPKE under a key derived from the PRF output (+ per-wrap salt)
    └─ passphrase wrap:  same, under Argon2id(passphrase, per-member salt)   [fallback]
server stores: ciphertext blobs + the wrapped-DEK blobs + wrap metadata. It can never unwrap.
  • A member SHOULD hold ≥2 wraps (two passkeys, or one passkey + passphrase). The UI nags
    until they do — this is the whole recovery story.
  • Sharing = an existing member, in-browser, unwraps the DEK and re-wraps it for the new
    member's enrolled public key. Requires the newcomer to have completed enrollment first
    (first-open ceremony). A member with no enrolled credential cannot be granted access —
    the UI must say that plainly rather than appearing to grant and silently failing.
  • Revocation = delete that member's wraps. Honest caveat for the UI: this stops future
    access; it cannot un-know a DEK they already unwrapped. (Rotation = new DEK + re-encrypt,
    P4 territory.)
  • No escrow, no break-glass, by design. P4's two-admin break-glass applies to at_rest
    ONLY. The create ceremony demands a typed folder name (spec §5 copy) plus the standard
    acknowledgment.

3. Server-side: mostly refusals, one new store

The whole point is that the server does less. Concretely:
- New table secure_folder_member_keys (folder_id, user_id, credential_id, wrap_kind, wrapped_dek, public_key, created_at) + endpoints to enroll/list/replace wraps. The server
validates SHAPE and ACL only; it never sees plaintext key material.
- folders.encryption = 'e2ee' (the CHECK value already exists from mig 00178) is accepted
at creation when the module is licensed and the caller holds folder.secure.
- Capability refusals (each an explicit task, each with a "why" in the UI, never a
broken button): preview/rasterize, extract/OCR → so no content_text, no search hit, no
semantic chunk, no Ask-the-Archive, no AI enrichment; OnlyOffice editing; e-sign,
e-Meterai, digital stamp; stego/egress marking; official-copy render; zip export;
public share links; MCP. Most are already refused for at_rest (share/MCP) or naturally
absent (no server bytes to render) — the work is making each refusal EXPLICIT and
explained rather than a 500 from a nil pipeline.
- Upload/download become opaque byte channels: the browser encrypts before upload and
decrypts after download. The sealed-namespace machinery from P2 is reused as-is; the
server just never holds a key for these folders.
- Step-up still gates the API surface (defense in depth, spec §5) — fetching ciphertext
and wrapped keys demands a live grant exactly as at_rest does.

4. Browser side

  • WebCrypto only (AES-GCM content, HKDF from the PRF output, AES-KW/HPKE for wraps).
    Streaming for large PDFs — chunked AEAD with a per-chunk counter, never a whole-file
    buffer (a 200MB scan must not blow the tab).
  • Enrollment ceremony on first open; unlock ceremony on entry (biometric prompt);
    in-memory DEK for the session window only, cleared on navigation away.
  • The disabled-capability UI: every unavailable action carries a tooltip saying WHY —
    "this folder is end-to-end encrypted; the server cannot read it to do this."

5. Verification (the definition of P3-done)

Beyond the usual build/vet/migration smoke:
1. The server-blindness proof, as the P2 seal proof was: create a Private Vault, upload,
then read the raw object AND every DB row out of the deployment and show neither yields
plaintext nor an unwrappable key. This is the demo that sells the tier.
2. Virtual-authenticator e2e with PRF enabled (the recipe in the passkey-step-up memory
works headless), covering: enroll → upload → reload → unlock → download round-trip;
second-passkey enrollment; member share; revoke; passphrase fallback path.
3. Every refusal asserted explicitly (search returns nothing, MCP refuses, preview refuses,
sign refuses) — a capability that silently half-works is the failure mode here.
4. Browser matrix note: at minimum Chromium headless with PRF; record what was NOT tested.

6. Sequencing

T1 migration + member-key store · T2 enrollment/wrap endpoints + ACL rules · T3 browser
crypto lib (encrypt/decrypt/stream) · T4 create ceremony + preset + copy · T5 capability
refusals (the long tail) · T6 sharing/revocation UI · T7 verification.

Ship T1–T4 behind the preset being invisible until T5 lands: a tier that is 80% refusals is
only safe to expose once all of them exist.