Encrypt uploaded files at rest, like note bodies already were

Note text was encrypted from the start; the files beside it were not. A
copy of the uploads directory handed over every image in the clear while
the notes referring to them stayed unreadable — half a protection, and
the half that is easiest to walk off with.

Same key and cipher as note content, so a backup is worth exactly as much
as its .env either way, which was already true and is now true uniformly.

Files carry an INKENC01 header and say for themselves whether they are
encrypted. That is what makes the startup sweep idempotent, and it had to
be the file rather than a database row: attachments are shared between
users by content hash, so no single row owns one.

Named by the hash of the plaintext, so identical uploads still collapse
into one copy — a fresh IV per file would have defeated deduplication.
Read whole rather than streamed, because GCM only proves integrity at the
last block. Written through a temporary name and renamed, so a crash
cannot strand a half-encrypted file that would fail its tag forever.

Verified: the one existing attachment went from 78 to 114 bytes with the
header in front, a second start did nothing, and images still load in the
browser after a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 19:11:08 +02:00
1 parent df279a5d36
commit 915856e1a4
2 files changed
+129 -4

No files matched your search

+27 -1
View File
@@ -24,7 +24,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
- **Offline-First** — Works fully offline, syncs when connection returns
- **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server
- **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
sign-up: an administrator creates the accounts
- **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density
@@ -409,6 +409,32 @@ a snippet around the first match.
---
## Encryption at rest
Note bodies and uploaded files are both encrypted with AES-256-GCM under
`ENCRYPTION_KEY`. Losing that key loses the notes — back it up with the database,
not next to it.
Files carry an 8-byte `INKENC01` header, so a file says for itself whether it is
encrypted. That is what lets the startup sweep run on every boot and encrypt only
what is still in the clear: files are shared between users by content hash, so a
flag on a database row could not have answered the question.
Two consequences worth knowing:
- **Deduplication still works.** Files are named by the hash of the *plaintext*,
so two identical uploads share one copy — encrypting each with a fresh IV would
otherwise produce two different files from the same picture.
- **Reads are not streamed.** GCM only proves a file is intact once the final
block is in, so an attachment is read whole and verified before anything is
sent. Half of a corrupt image is worse than an error.
Uploads are written to a temporary name and renamed into place. A rename is
atomic on one filesystem, so a crash mid-write leaves the old file or the new
one, never a half-written one that would fail its tag forever after.
---
## Attachments
Images used to be inlined into the note body as base64 data URIs — a few