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.
- Account: the key derives from the password.
- Anonymous: the browser generates a random content key on first use and keeps it in localStorage. It never leaves the browser. Clearing site data loses it, the same way clearing the cookie already loses the anonymous library. Registering from that browser adopts that key as the account key, so nothing imported before signup is lost.
Keys
Three layers. Password -> KEK -> content key -> book text.
salt: 16 random bytes per user, stored inusers.kdf_salt. Generated in the browser at registration.KEK = argon2id(password, salt), 32 bytes. Parameters inusers.kdf_params, currently{"alg":"argon2id","t":3,"m":65536,"p":1,"len":32}(3 passes, 64 MiB, 1 lane). Computed only in the browser (argon2-browser, wasm, vendored infrontend/vendor/). The server never computes it.auth = HKDF-SHA256(KEK, info="elocute-auth-v1"), 32 bytes. This is what the browser sends at login instead of the password. The server storesbcrypt(auth)inusers.password_hashand marksusers.auth_version = 1. HKDF is one way, so a server that reads its own memory at login time learns nothing that unwraps a key.WK = HKDF-SHA256(KEK, info="elocute-wrap-v1"), an AES-256-GCM key that wraps the content key. Separate fromauthon purpose.CK: 32 random bytes per user, the content key. Stored only wrapped:users.wrapped_key = nonce(12) || AES-256-GCM(WK, nonce, CK, aad="elocute-wrap-v1"). Password change re-wraps CK, it does not touch the books.
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:
- version 1: payload is the UTF-8 text.
- version 2: payload is gzip(UTF-8 text). Ciphertext does not compress, so
the browser gzips before encrypting when
CompressionStreamexists (every current browser). A book of a few MB of prose then costs a third of the bytes on the wire and in the database.
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:
POST /api/import(file, link or pasted text) returns the prepared book (title, author, chapters, cover, text) and stores nothing.- The browser encrypts the text with CK and
POST /api/booksstores 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
GET /api/kdf?email=returns{salt, params, legacy}. Unknown emails get a deterministic fake salt (HMAC of the email underSECRET_KEY) so the endpoint does not reveal whether an account exists.- The browser derives KEK, sends
{email, auth}toPOST /api/login. - The server checks
bcrypt(auth), binds the session, claims anonymous books, and returns{wrapped_key, salt, params, claimed}. - 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:
- 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. python scripts/migrate_encrypt.pyprompts for an email and password, upgrades that account toauth_version = 1if needed, encrypts every plaintext book of that account, verifies a decrypt round trip of each, and nullstext_contenton success. Run once per account whose password the operator holds. Safe to rerun.python scripts/migrate_encrypt.py --finalizelists every row still in plaintext (anonymous books nobody re-opened, accounts whose password the operator does not have), asks for confirmation, deletes them, and dropstext_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.