This is a narrative summary aimed at engineers evaluating the design. The canonical reference is docs/full-encryption.md in the PlikShare repo. Anything that conflicts with it has drifted and the docs win.
What full encryption is, and what it isn't
Full encryption is the mode where the database and the file storage never hold plaintext. File contents are ciphertext on disk. File names, folder names, share-link names, and the content-bearing fields of audit log entries are ciphertext in the database. The keys that unlock all of it are not in the database. They are wrapped under each user's encryption password, with public-key sharing built on X25519 sealed boxes.
It is not end-to-end encryption. PlikShare is a server-rendered application: files are read and written through the server, with previews, range reads, search, and audit lookups all running server-side. To do any of that, the server has to unwrap keys into RAM for the duration of a request. The framing that matches what's actually true is "the database and file storage never hold plaintext; keys are briefly unwrapped in server memory per request, then zeroed". Calling it E2E would be incorrect and worse than the truth, which is already interesting.
Three design goals (and one out-of-scope)
- Resilient to database and storage compromise. An attacker who obtains the SQLite database, the file storage, or both simultaneously cannot read file contents, file names, folder names, or audit log details. The database holds only ciphertext, wrapped keys, public keys, salts, and verify hashes. Storage holds only encrypted file frames.
- Encryption scoping. Encryption keys are scoped hard to the resources a user has access to. A user with access to one workspace holds key material that decrypts that workspace and nothing else. Possessing their unwrapped private key does not unlock data they were never granted access to. A database or storage compromise plus a phished encryption password from one user must not cascade into a global decryption.
- Recoverable from a recovery code alone. If the database is lost, a user holding a recovery code can still decrypt files left in storage. The recovery code is the disaster-recovery root. No DB row, no admin assistance, no backup of any wrapped key is required.
What is explicitly out of scope: an attacker with live access to the server process, capable of dumping process memory while keys are unwrapped. Any in-process cryptography requires keys in plaintext at the moment of use. Mitigation focuses on minimizing that window: unwrapped keys are held in protected memory (pinned, mlocked, zeroed on dispose), released at the end of the request that needed them, and kept off the GC heap where possible. But the window exists. Anyone who tells you their server-side encryption is E2E is either confused or lying.
User identity: password, keypair, recovery mnemonic
Every user who wants to use full-encryption features sets up an encryption password. Setting it generates an X25519 keypair (public + private) and a 32-byte recovery seed. The password and the recovery seed are each used for two things: deriving a verify-hash (so the password or recovery code can be checked at unlock time without trying an unwrap) and deriving a KEK that wraps the private key. The private key is never stored in the database in plaintext. Only its wrapped forms are.
The recovery seed is encoded as a 24-word BIP-39 mnemonic and returned to the user exactly once. It is never persisted server-side. The user is responsible for storing the mnemonic somewhere safe. It is the only way to recover their private key if they forget the encryption password. The seed is therefore as sensitive as the password itself.
Sealed-box sharing
The X25519 keypair is the foundation of how full-encrypted workspaces are shared. Each user has one keypair and one encryption password. A single password unlocks every workspace ever shared with them. The asymmetric construction has a second useful property: workspaces can be shared with a user offline. The inviter only needs the recipient's public key, which lives in the database in plaintext.
The construction is the standard libsodium-style sealed box (crypto_box_seal):
1. Generate ephemeral X25519 keypair (eph_priv, eph_pub).
2. shared = X25519(eph_priv, recipient_pub).
3. Derive AEAD key = HKDF-SHA256(
ikm = shared,
salt = empty,
info = "plikshare-sealed-box-v1\0",
length = 32)
4. Encrypt payload with ChaCha20-Poly1305 under that key.
5. Output envelope: eph_pub(32) | nonce(12) | ciphertext | tag(16)Every wrap of a workspace key, every wrap of a storage key, every per-recipient envelope in full-encryption mode uses this primitive. Unsealing requires only the recipient's private key: no coordination with the sender, no online step.
The key hierarchy
Starting from one random seed (generated when a storage is created), HKDF produces a tree of keys that isolate workspaces within a storage and, in the future, boxes within a workspace:
Two important properties fall out of this shape. The seed alone reaches everything below it: hold the seed, derive any Storage DEK by version, walk the chain salts in each file header down to its per-file AES key. And each scope's salts live in the file's own header: a future offline recovery tool with only the seed and the file bytes can reconstruct the key chain without consulting the database.
Unlock and the encryption session
Accessing a full-encryption resource requires the user's private key in memory, which requires an unlock. Unlock does not write to the database. It verifies the password (via a stored verify-hash derived from the KEK), unwraps the private key, and seals it into a server-side encrypted cookie scoped to the user's external id.
Every subsequent request that touches a full-encrypted resource decrypts that cookie server-side to obtain the private key, uses it to unseal the relevant workspace's DEKs, and drops them into a WorkspaceEncryptionSession parked in HttpContext.Items for the duration of the request. The session's lifetime is bound to the HTTP request: the unsealed private key is wiped as soon as the unwrap finishes; the unsealed workspace DEKs sit in protected memory and are zeroed when the response is flushed, via a dispose hook registered on the response. Nothing the unlock produces survives past the request that produced it. Every subsequent request starts from the cookie again.
POST /api/user-encryption-password/lock deletes the cookie. There is no server-side session state to clear.
Two paths to a workspace DEK
A user can reach a workspace's DEKs through two paths:
The member path goes through wek_workspace_encryption_keys. A wrap of the Workspace DEK sealed to the user when they were added. This is how invited members read a workspace.
The storage-owner path goes through sek_storage_encryption_keys. A wrap of the Storage DEK sealed to a storage admin. From the Storage DEK plus the workspace's salt (in the database), the Workspace DEK is derived. This is the same derivation an offline recovery tool would walk from the seed. A storage owner can read every file in every workspace on their storage without ever being added as a workspace member.
If both paths come up empty, the user is authenticated and unlocked but is not a member of the workspace and not an owner of its storage. The request is rejected before reaching the handler.
The V2 file frame
Full-encryption workspaces use the V2 file frame, which differs from managed-encryption's V1 in one important way: it carries an explicit format-version byte and a serialized key-derivation chain in the header. The reader knows which Storage DEK version produced the file, and what salts to walk down to the per-file AES key, without consulting any external state.
Today CHAIN_STEPS_COUNT is always 1 (the workspace salt). The format generalizes to N steps so that future scope levels (per-box keys, per-share-link keys, per-creator keys) can be appended to the chain without changing the format. A tool written years from now decrypts files written today, regardless of how many scope levels exist at that point.
On the hot path the server doesn't actually walk the chain salts in the header: the in-memory WorkspaceEncryptionSession already holds the unsealed Workspace DEK, and the file's database row carries FILE_SALT and NONCE_PREFIX directly. The chain salts in the header exist for a different consumer: an offline recovery tool with only the seed and the file. Same destination, two paths.
Metadata encryption: the pse: prefix
File contents are the obvious target. File names, folder names, link names, comment bodies are the less obvious one. All of them leak the names and structure of stored data if kept in the clear.
In full-encryption mode, every text metadata field is encrypted with AES-256-GCM under the Workspace DEK and stored as the literal string pse: followed by a base64-encoded envelope:
The column type stays TEXT regardless of whether the workspace is encrypted: same column, different content. The pse: prefix is a hard self-identifying tag: any tooling, any DB dump, any decode routine can immediately tell encrypted metadata from plaintext. Request validation rejects user-supplied metadata starting with pse:, so the prefix is never ambiguous.
A fresh 12-byte random nonce per encrypted value means the same plaintext encrypts to a different ciphertext every time. Equality between two encrypted values reveals nothing.
Search over ciphertext
Search on full-encrypted workspaces is available to users who hold the relevant Workspace DEKs in their session. It is implemented by registering a SQLite user-defined function app_decrypt_metadata that recognizes encrypted text by the pse: prefix and decrypts the envelope using a Workspace DEK looked up from the request's session map (keyed by workspace_id):
SELECT *
FROM fi_files
WHERE fi_workspace_id IN (...)
AND app_decrypt_metadata(fi_workspace_id, fi_name) LIKE :patternThe session map is built once at the start of the request: every workspace the searching user has access to has its DEKs unsealed and held in protected memory. The UDF runs row-by-row inside the query engine, so decrypted plaintext lives only inside the function callback for the duration of the row's evaluation. There is no index, so this is a full scan over the workspace's metadata. Workspaces are bounded, so the scan runs at I/O speed.
Audit logs: encrypted details, plaintext skeleton
The audit log records every meaningful action: workspace created, member invited, file uploaded, folder renamed, link generated. Each entry has a structured details payload: for a rename, the old and new names; for an upload, the file path; for an invitation, the email of the invitee. A workspace whose files are encrypted at rest still leaks every name they ever held if the audit log is in the clear.
The fix: encrypt the sensitive fields of details with the same envelope used for file metadata: AES-256-GCM under the Workspace DEK, base64-encoded, pse: prefixed. Same primitive, same key, same prefix discipline. There is no separate audit-log key. Whoever has access to a workspace's contents has access to its history, and vice versa.
The split is deliberate: an admin can run "show me every failed login in the past week" or "every bulk delete in workspace W" without unlocking any encryption session, because the answers come from plaintext columns. But "what was the file called that Alice renamed yesterday" needs the Workspace DEK.
If the admin has no encryption session at all, the request returns 423. If the admin has a session but no access to the workspace the entry concerns (they are an app admin but not a member of that workspace and not an owner of its storage), the encrypted strings are replaced with the literal [encrypted] rather than decoded. Application-admin role does not grant cryptographic access; that has to come through the same wek/sek wraps as any other read.
Invitations: the bootstrapping problem
Sealing a Workspace DEK to a user requires that user's public key. New users do not have one yet. An inviter wants to add Alice to a workspace now, but Alice has not yet set an encryption password, so she has no keypair, so nothing can be sealed to her.
The solution is ephemeral keys. When Alice is invited, the server generates an ephemeral X25519 keypair for her, stores the public key, and encrypts the ephemeral private key under a KEK derived from her invitation code. The inviter seals the Workspace DEK to that ephemeral public key, landing in a TTL'd ewek_* row.
When Alice clicks the invitation link and sets an encryption password, the server uses the invitation code to decrypt the ephemeral private key, unwraps the ephemeral DEK wraps with it, generates Alice's real X25519 keypair, re-wraps the DEKs to her real public key (landing in wek_*), and deletes the ephemeral rows.
If Alice never accepts, a cleanup job deletes the ephemeral rows after the TTL expires and the inviter has to re-invite. The sealed-box mechanism means the inviter never needs Alice's eventual private key, only a public key to seal to, even an ephemeral one.
Encryption-password recovery
If a user forgets their encryption password, the only way back to their private key is the 24-word BIP-39 mnemonic returned when the password was first set. The mnemonic decodes into a 32-byte recovery seed, the seed derives a recovery KEK (via HKDF with a fixed info string), and the recovery KEK unwraps a recovery-side copy of the private key kept in the user's EncryptionMetadata.
Recovery resets the password but preserves the X25519 keypair. The user's public key is the same after recovery, so every workspace wrap sealed to them remains valid. Only the password-side wrapping is rotated: a new KdfSalt, KdfParams, VerifyHash, and EncryptedPrivateKey are written; the recovery side stays unchanged so the same mnemonic works again next time.
The recovery seed is therefore as sensitive as the password itself: anyone holding it can reset the password and take over the encryption session without ever knowing the current password. Treating the mnemonic as a password-equivalent secret is essential. Losing control of it is equivalent to losing control of the account.
Storage recovery: a path back to the bytes
Beyond per-user password recovery, the format itself is recoverable from a single 24-word mnemonic at the storage level. When a storage is created, the same kind of recovery code is returned to the creator. If the database is ever lost (and with it every wrapped DEK), the storage seed alone is enough.
Every V2 file header carries the Storage DEK version and the chain of salts down to the per-file AES key, so the only missing input is the Storage DEK itself. Given the mnemonic, DeriveDek(seed, v) reproduces it deterministically, the header provides every salt below it, and an offline tool can walk encrypted frames straight off a bucket without a running server.
Like with managed encryption, what the recovery code cannot reconstruct is anything that lived only in the database: workspace structure, audit history, share links, user accounts. Recovery is a path back to the bytes, not back to the application state.
When to pick full encryption
Full encryption is the answer when the threat model includes the server itself: a hosting compromise, a malicious admin, a subpoena reaching the running process, a leaked deployment env var. It is the right choice when file names and folder paths must not leak from the database, when audit history must not leak file paths or renamed names, and when storage admins need cryptographic access (not just role-based access) to the workspaces they host.
It costs per-user setup: every user has to configure an encryption password and keep their recovery mnemonic safe. Users who lose both lose access to every workspace shared with them. If those constraints don't fit, managed encryption is the simpler default: the server holds the keys, no per-user setup, no recovery mnemonic to lose, with file storage still encrypted at rest.
Where to go next
For the parts this article skipped (membership revocation, storage DEK rotation, the planned box-level scoping and per-creator keys, the byte-level layouts and the exact derivation strings), the canonical reference is docs/full-encryption.md in the repo. The companion piece on the simpler mode lives at /blog/managed-encryption.
Damian Krychowski