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

+102 -3
View File
@@ -345,6 +345,57 @@ function encrypt(text) {
return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`;
}
/* ─── Files at rest ───
*
* Note bodies were encrypted from the start, but uploaded files were not: a
* copy of the uploads directory handed over every image in the clear while the
* notes around them stayed unreadable. Same key, same cipher, so a backup is
* worth exactly as much (or as little) as its .env either way.
*
* The header lets a file say for itself whether it is encrypted, which is what
* makes the migration below safe to run again and again: files are shared
* between users by content hash, so marking a database row would not have been
* enough to tell.
*/
const FILE_MAGIC = Buffer.from("INKENC01", "ascii"); // 8 bytes
const IV_BYTES = 12;
const TAG_BYTES = 16;
function encryptBuffer(plain) {
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv);
const enc = Buffer.concat([cipher.update(plain), cipher.final()]);
return Buffer.concat([FILE_MAGIC, iv, cipher.getAuthTag(), enc]);
}
const isEncryptedFile = (buf) =>
buf.length >= FILE_MAGIC.length && buf.subarray(0, FILE_MAGIC.length).equals(FILE_MAGIC);
/**
* Writes through a temporary file and renames.
*
* A rename is atomic on the same filesystem, so a crash mid-write leaves either
* the old file or the new one — never a half-written attachment that would fail
* its authentication tag and read as corrupt forever after.
*/
function writeFileAtomic(filePath, bytes) {
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, bytes);
fs.renameSync(tmp, filePath);
}
function decryptBuffer(buf) {
// Files written before this existed are still plain bytes; they read straight
// through until the sweep below gets to them.
if (!isEncryptedFile(buf)) return buf;
const at = FILE_MAGIC.length;
const iv = buf.subarray(at, at + IV_BYTES);
const tag = buf.subarray(at + IV_BYTES, at + IV_BYTES + TAG_BYTES);
const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(buf.subarray(at + IV_BYTES + TAG_BYTES)), decipher.final()]);
}
function decrypt(data) {
const s = String(data);
if (s.startsWith("v2:")) {
@@ -1029,6 +1080,42 @@ if (!fs.existsSync(ATTACHMENT_DIR)) fs.mkdirSync(ATTACHMENT_DIR, { recursive: tr
const ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]);
const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
/**
* Encrypts any attachment still lying on disk in the clear.
*
* Runs at every start and is idempotent: the header says whether a file has
* already been done, so an interrupted sweep simply resumes. Each file is
* written through a temporary name and renamed, so a crash cannot leave one
* half-encrypted — the state on disk is always either fully one or the other.
*/
(function encryptExistingAttachments() {
let files;
try {
files = fs.readdirSync(ATTACHMENT_DIR);
} catch {
return;
}
let done = 0;
let failed = 0;
for (const name of files) {
if (name.includes(".tmp-")) continue; // leftover from an interrupted run
const filePath = path.join(ATTACHMENT_DIR, name);
try {
if (!fs.statSync(filePath).isFile()) continue;
const buf = fs.readFileSync(filePath);
if (isEncryptedFile(buf)) continue;
writeFileAtomic(filePath, encryptBuffer(buf));
done++;
} catch (err) {
console.error(` ⚠️ could not encrypt attachment ${name}: ${err.message}`);
failed++;
}
}
if (done) console.log(` 🔒 encrypted ${done} attachment file(s) that were stored in the clear`);
if (failed) console.error(` ⚠️ ${failed} attachment file(s) could not be encrypted — they still read fine`);
})();
app.post("/api/attachments", authMiddleware, (req, res) => {
const { data, filename } = req.body || {};
if (typeof data !== "string") return res.status(400).json({ error: "data required" });
@@ -1052,8 +1139,10 @@ app.post("/api/attachments", authMiddleware, (req, res) => {
const existing = db.prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?").get(req.userId, sha256);
if (existing) return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true });
// Named by the hash of the plaintext, so identical files still land on one
// copy — encryption uses a fresh IV each time and would otherwise defeat that.
const filePath = path.join(ATTACHMENT_DIR, sha256);
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, buf);
if (!fs.existsSync(filePath)) writeFileAtomic(filePath, encryptBuffer(buf));
const id = uuid();
db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)")
@@ -1071,11 +1160,21 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
const filePath = path.join(ATTACHMENT_DIR, a.sha256);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" });
let bytes;
try {
// Read whole rather than streamed: GCM only proves the file is intact once
// the last block is in, and half a corrupt image is worse than an error.
bytes = decryptBuffer(fs.readFileSync(filePath));
} catch (err) {
console.error(` ❌ could not decrypt attachment ${a.id} (${a.sha256}): ${err.message}`);
return res.status(500).json({ error: "File could not be read" });
}
res.setHeader("Content-Type", a.mime);
res.setHeader("Content-Length", a.size);
res.setHeader("Content-Length", bytes.length);
res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes
res.setHeader("X-Content-Type-Options", "nosniff");
fs.createReadStream(filePath).pipe(res);
res.end(bytes);
});
/* Two routes used to sit here and were removed on 2026-08-08:
*
+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