🔊 Elocute Back to your library

Encryption at rest for book text

Elocute is going to run on a rented VPS. The provider can read the disk and the memory of that box. The property this design reaches is that the text of a book is never stored in plaintext and that the server never holds a key that can decrypt it. Decryption happens in the browser, with a key that only the browser and the user's password can produce.

Identities

Elocute is anonymous first. A visitor gets a random anonymous id in the session cookie and can import books without an account. An account (email + password) binds those books to a user row and syncs them across devices. Both identities are covered.

Keys

Three layers. Password -> KEK -> content key -> book text.

Cipher for the text

AES-256-GCM through WebCrypto in the browser and cryptography in Python. libsodium was not already a dependency on either side and WebCrypto ships in every browser, so AES-GCM wins on dependency count.

books.text_enc = nonce(12) || AES-256-GCM(CK, nonce, payload, aad="elocute-book-v<n>") with a random 96 bit nonce per row and books.enc_version = n:

Random 96 bit nonces are safe at this volume (birthday bound is 2^32 messages under one key, a user has at most thousands of books).

Where decryption happens

Reading is entirely in the browser. Text to speech is the browser's Web Speech API, there is no server side TTS, chunking or search. The reader fetches GET /api/books/{id}/text (bytes, X-Enc-Version header), decrypts with CK, then does what it did before with the plaintext. A "Download text" link in the reader gives the user their book back as a .txt, decrypted locally.

Import is the one place the server sees plaintext. PDF and EPUB extraction and the text cleaning heuristics live in backend/bookprep.py (PyMuPDF, ebooklib), and moving that into the browser is a rewrite. So import is two requests:

  1. POST /api/import (file, link or pasted text) returns the prepared book (title, author, chapters, cover, text) and stores nothing.
  2. The browser encrypts the text with CK and POST /api/books stores the ciphertext plus metadata.

During step 1 the upload and the extracted text exist in the server's memory. Starlette spools uploads above 1 MB to a temp file, so the container mounts /tmp as tmpfs (memory) and nothing lands on disk. The server never logs the text. A provider reading memory at the moment of an import can see that one book, and that is the residual exposure of this design. Everything at rest is ciphertext.

Title, author, chapter titles, word count, cover image and reading position stay in plaintext. They are needed for the library page and are far less sensitive than the text. Stated on the privacy page.

Login flow

  1. GET /api/kdf?email= returns {salt, params, legacy}. Unknown emails get a deterministic fake salt (HMAC of the email under SECRET_KEY) so the endpoint does not reveal whether an account exists.
  2. The browser derives KEK, sends {email, auth} to POST /api/login.
  3. The server checks bcrypt(auth), binds the session, claims anonymous books, and returns {wrapped_key, salt, params, claimed}.
  4. The browser unwraps CK and keeps it in localStorage for this browser (cleared on logout). Books claimed from the anonymous identity were encrypted under the anonymous key, so the browser re-encrypts each one under CK and PUTs it back before redirecting.

The key lives in localStorage rather than sessionStorage so that reopening the tab does not ask for the password again. The session cookie already grants the same access on that device for a year, so this adds no exposure beyond what the cookie gives.

Accounts created before this change (auth_version = 0, bcrypt(password)) log in with the password once. The browser then generates the key bundle and calls POST /api/keys/setup with the password (verified again) and the new bundle. From then on the account is auth_version = 1. Their existing plaintext books are encrypted by the browser the first time each is opened, or by the operator's migration script.

Password change and reset

Change: the browser derives both KEKs, unwraps CK with the old, wraps with the new, and POST /api/keys/setup {auth, new_salt, new_params, new_auth, new_wrapped_key}. No book is touched.

Reset: there is none. The password is the only way to produce KEK, so a reset without the old password would leave the books unreadable. The registration form says this in plain words. A user who loses the password can delete the account and start over.

Account deletion and anonymous forget

DELETE /api/account with {auth} (or {password} for a legacy account) deletes the user row, every book under it (cascade) and the session. For an anonymous identity the same endpoint deletes the anonymous books and drops the identity. Linked from the account area and the privacy page.

Access logs

access_log(ts, ip, ua, method, path, status) is written by a middleware for API requests (not the reading position heartbeat, not static files, not /healthz). uvicorn's own access log is off, so this table is the only place an IP is recorded. Rows older than ACCESS_LOG_RETENTION_DAYS (default 60) are deleted at startup and every 24 h.

Schema

users  + kdf_salt BYTEA, kdf_params JSONB, wrapped_key BYTEA,
         auth_version SMALLINT DEFAULT 0
books  + text_enc BYTEA, enc_version SMALLINT
       - text_content (dropped by the migration's finalize step)
access_log (new)

db.init() adds the columns and detects whether text_content still exists, so one build serves both before and after the migration.

Migration

One deploy, then the operator runs scripts/migrate_encrypt.py:

  1. Deploy this code. New imports are encrypted from that moment. Legacy rows still serve as plaintext (X-Enc-Version: 0) and the browser encrypts each one on first open.
  2. python scripts/migrate_encrypt.py prompts for an email and password, upgrades that account to auth_version = 1 if needed, encrypts every plaintext book of that account, verifies a decrypt round trip of each, and nulls text_content on success. Run once per account whose password the operator holds. Safe to rerun.
  3. python scripts/migrate_encrypt.py --finalize lists every row still in plaintext (anonymous books nobody re-opened, accounts whose password the operator does not have), asks for confirmation, deletes them, and drops text_content. Anonymous legacy books cannot be encrypted by the operator because there is no key to encrypt them to.

Both steps print what they are about to do and the counts afterwards.